diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cb412e..5213e62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,8 @@ docs/ │ ├── paper-conformance-analysis.md # Living deviation register + pinned audit snapshot │ ├── paper-conformance-remediation.md # Gates A–C │ ├── lightning-bridge.md # Off-by-default operator extension (Lightning) -│ └── mail-bridge.md # Off-by-default operator extension (mail) +│ ├── mail-bridge.md # Off-by-default operator extension (mail) +│ └── group-chat.md # v2 Marmot/MLS group overlay — NOT applicable in v1 ├── src/css/custom.css # Theme overrides (Bitcoin orange) ├── static/img/ # Favicon, logos ├── docusaurus.config.js # Site config diff --git a/README.md b/README.md index 225bd2a..c816e4c 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ zkCoins spans several repositories; the specification in this repo covers all of |---|---|---| | **App · Explorer** | end-user wallet (NIP-05 `user@domain` receive identity) · public explorer web-app | [`zk-coins/app`](https://github.com/zk-coins/app) · `zk-coins/explorer` *(planned)* | | **SDK** | thin TypeScript client — on-device keys, signing, node/API calls | [`zk-coins/sdk`](https://github.com/zk-coins/sdk) | -| **zkCoins API** | the sole public REST surface — features `wallet`, `explorer`, `publisher`, Lightning and mail bridges, each switched on by the operator | [`zk-coins/api`](https://github.com/zk-coins/api) | +| **zkCoins API** | the sole public REST surface — features `wallet`, `explorer`, `publisher`, and Lightning and mail bridges, each switched on by the operator; `group_chat` is a **v2 feature — NOT applicable in v1** | [`zk-coins/api`](https://github.com/zk-coins/api) | | **zkCoins node** | trustless kernel, gRPC only — scan · accumulator · verify · prove · store · publisher · Nostr transport | [`zk-coins/node`](https://github.com/zk-coins/node) | | **bitcoind · Nostr relay** | Bitcoin L1 settlement and ordering · off-chain transport and data availability | upstream (own or external) | diff --git a/docs/architecture.md b/docs/architecture.md index edce1e5..46f9b0e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,6 +79,8 @@ Each feature is off until the operator enables it, and `GET /v1/info` advertises | `lightning_bridge` | Lightning ⇄ zkCoins swaps at the operator edge ([Lightning bridge](/lightning-bridge)) | | `mail_bridge` | SMTP interop for the account's NIP-05 identifier ([Mail bridge](/mail-bridge)) | +`group_chat` is a **v2 feature — NOT applicable in v1** ([Group chat](/group-chat)). + Publishing and proving are kernel work in every case; the feature opens the door, the kernel does the job and owns the state. ## Deployments diff --git a/docs/assurance.md b/docs/assurance.md index 4751003..ef69d7c 100644 --- a/docs/assurance.md +++ b/docs/assurance.md @@ -11,7 +11,7 @@ title: Assurance Roadmap Two distinct failure classes threaten the protocol, and they need different instruments: - **Incentive failures.** Every role behaves as specified only if behaving as specified is that actor's best strategy. Cryptographic proofs do not cover this: a protocol can be mathematically sound and still fail in production because rational operators act differently than the design assumes. Incentive failures surface only once the network is live and someone exploits them — and a live decentralized system cannot simply be patched. They must be found **before** launch, by analysis. -- **Implementation failures.** The specification can be right and the code wrong. zkCoins deliberately runs a **single protocol implementation** (the Rust node; the TypeScript SDK independently re-implements only the client-side primitives — derivation, hashing, and signing). There is no second full implementation to cross-check against, so the independent view a second client would provide must come from elsewhere: the executable conformance harness — the specification's pinned test vectors, their negative controls, and the A-to-Z suite of the [Implementation Mandate](/implementation-mandate). By explicit project decision v1 ships **without an external audit**; the harness and the specification's internal soundness argument ([spec §2.4](/specification#24-soundness-summary)) carry that assurance weight. +- **Implementation failures.** The specification can be right and the code wrong. zkCoins deliberately runs a **single protocol implementation** (the Rust node; the TypeScript SDK independently re-implements only the client-side primitives — derivation, hashing, and signing). There is no second full implementation to cross-check against, so the independent view a second client would provide must come from elsewhere: the executable conformance harness — the specification's pinned test vectors, their negative controls, and the A-to-Z suite of the [Implementation Mandate](/implementation-mandate). The harness and the specification's internal soundness argument ([spec §2.4](/specification#24-soundness-summary)) carry that assurance weight. Both workstreams follow the project's standing rule: whatever they change flows through this repository as a spec PR first ([Implementation Mandate](/implementation-mandate)). @@ -38,7 +38,7 @@ The path to "demonstrably secure", in order — each step builds on the previous 1. **Security definitions.** Precise statements of what *secure* means for zkCoins: no forgery, no double-spend, and privacy of amounts, assets, and participants expressed as indistinguishability properties — stated precisely enough to capture the linkability gaps [Risks](/risks) already documents (intra-transaction co-output visibility via the shared `output_coins_root`, and pre-anchor disclosure of `Pkᵢ` to a chosen publisher, and — under the deferred fee mechanism only — cross-transition publisher linkage via the fee-coin `ash` chain), so v1's actual unlinkability guarantee is neither over- nor under-stated. The definitions become part of the [Specification](/specification). 2. **Paper proofs.** Reductions showing the protocol meets those definitions under standard assumptions (hash security, discrete log/Schnorr, the proof system's soundness and zero-knowledge). Written up, published, and reviewable. For v1, publication of these proofs is a quality goal, **not a release gate** (project decision: no human-gated mainnet step); the release-gating artefacts are the in-spec soundness argument and the executable harness. -3. **Machine-checked verification.** Machine-checked proofs or model checking for the protocol state machine where feasible. By explicit project decision there is **no external audit** for v1: the executable conformance harness — pinned vectors, negative controls, and the A-to-Z suite — stands in for the independent check a second implementation or an external audit would otherwise provide. +3. **Machine-checked verification.** Machine-checked proofs or model checking for the protocol state machine where feasible. For v1 the executable conformance harness — pinned vectors, negative controls, and the A-to-Z suite — is the independent check. :::info Proofs cover the model, not the code A security proof establishes that the *specified* protocol is sound. That the *running* code implements the specified protocol is established separately — by the pinned conformance vectors and the end-to-end suite the [Implementation Mandate](/implementation-mandate) requires. Only both halves together justify the claim "demonstrably secure". diff --git a/docs/group-chat.md b/docs/group-chat.md new file mode 100644 index 0000000..742be15 --- /dev/null +++ b/docs/group-chat.md @@ -0,0 +1,207 @@ +--- +title: Group Chat +--- + +# Group Chat + +:::info v2 feature — NOT applicable in v1 +The Marmot/MLS group overlay is a **v2 feature**. This page is **normative for protocol version v2** and is recorded here for continuity ([spec · Protocol versions](/specification#protocol-versions-token-standards-and-document-editions)). A **v1** node, API, SDK, or app **MUST NOT** advertise `group_chat`, **MUST NOT** add it to the v1 `features` or `kernel_parts` sets, **MUST NOT** serve `/v1/groups*` or `/v2/groups*` (when v2 activates, the routes are `/v2/groups*`), **MUST NOT** expose the group kernel procedures, and **MUST NOT** process kinds `444` / `445` / `30443` as group chat. V.12, A-to-Z, and payment conformance do not depend on this overlay. When v2 activates it, an operator enables `group_chat` independently of the Lightning and mail bridges and of mandatory NIP-17 one-to-one messaging. It moves messages, never zkCoins value. Enabling it **MUST NOT** change NIP-17 event formats, relay selection, or one-to-one interoperability. +::: + +## Adoption, not a fork + +zkCoins adopts Marmot protocol, the Nostr transport binding `marmot.transport.nostr` version 1, and MLS [RFC 9420](https://www.rfc-editor.org/rfc/rfc9420.html). The adopted Marmot revision is git commit [`4a2bc65f8db5`](https://github.com/marmot-protocol/marmot/tree/4a2bc65f8db5) of [https://github.com/marmot-protocol/marmot](https://github.com/marmot-protocol/marmot) (2026-08-13, “Specify durability and restart contract”). + +zkCoins **MUST NOT** define a parallel group crypto. Where this page and Marmot conflict on MLS or transport bytes, Marmot wins. This page owns only the zkCoins binding: the feature flag, key custody, hosted trust, conversation separation, payment safety, REST, and the negative controls. + +A node that advertises API `group_chat` **MUST** speak that pinned transport binding. An implementation **MUST NOT** emit deprecated NIP-EE kind `443` KeyPackages, NIP-04, NIP-29, or a multi-recipient NIP-17 rumor as a group. + +## Relationship to NIP-17 + +One-to-one human messaging stays NIP-17 kinds `14` / `13` / `1059` / `10050` with **exactly one** recipient `p` ([spec §7.3](/specification#73-nostr-event-kinds-normative), V.12 `M-08`). An extra `p` tag remains invalid for the zkCoins one-to-one profile. A group is not a NIP-17 room. + +Group conversations and one-to-one conversations **MUST** stay separate in the UI. A client **MUST NOT** silently fall back between NIP-17, SMTP, and Marmot. Kinds `1420` and `1421` remain payment-only and **MUST NOT** carry group plaintext. + +Disabling `group_chat` **MUST NOT** disable NIP-17, NIP-05 identity, native zkCoins delivery, or either bridge ([spec §4.3](/specification#43-addressing-for-delivery)). + +## Identity + +The Marmot account identity **is** the account's `op_pubkey`. Contacts remain keyed by `op_pubkey`. Kind `10050` is reused unchanged as the Marmot inbox relay list for Welcomes. + +When `group_chat` is on, the account **MUST** publish a valid kind-`10002` NIP-65 relay list so others can fetch KeyPackages from the write-capable relay set. A write-capable entry is an `r` tag whose marker is `write` or whose marker is omitted; an entry marked only `read` is not in the set. + +An account that has no valid kind-`10050` event is not ready for Welcomes. A sender **MUST** fail closed and **MUST NOT** guess a relay (G-10). Contextual relay hints that Marmot permits as advisory delivery **MUST NOT** substitute for a missing kind-`10050` list on this binding. + +## Key custody + +MLS leaf secrets and HPKE `init_key` private material **MUST** be generated by a CSPRNG. Epoch secrets, exporter secrets, and `group_event_key` **MUST** be derived by the MLS key schedule / `MLS-Exporter("marmot", "group-event", 32)` of the current epoch. This material **MUST NOT** be derived from `op`, `op_secret`, `nk`, `ivk`, `ovk`, SPEND, the seed, or any HKDF tag already used in [spec §1](/specification#1--foundations-normative). + +`op` **MAY** sign Marmot account-identity proofs and kind-`30443` KeyPackage events (author = `op_pubkey`). `op` **MUST NOT** spend and **MUST NOT** derive MLS secrets. + +The node that holds the operational bundle `{ivk, ovk, op, nk, op_secret}` holds the MLS client credential store for that account, next to the bundle, as non-spend operational state. + +A hosted provider who holds `op` can decrypt group application messages and impersonate the account in groups. That is the existing operational-bundle boundary ([spec §6.6](/specification#66-threat-model-and-trust-configurations)), not a new one. Self-hosting is the private path. Wallet-held MLS keys **MUST NOT** be presented as a hosted end-to-end exception. + +MLS state is **non-value-bearing operational state**. Losing it loses group membership and history, never coins. Group state **MUST** be listed among the [spec §6.3](/specification#63-node-portability-and-multi-node-operation) residuals: a node switch does not reconstruct it from seed + Bitcoin. + +[Requirement 12](/requirements#12-data-permanence) and [spec §4.8](/specification#48-durability--the-store-everything-invariant) apply in full to every value-bearing artefact, to NIP-17 messages the node stores, and to Marmot **commits and proposals**. Those **MUST NOT** expire. The sole carve-out is Marmot MLS **application** messages when `group_chat` is on **(v2 only — NOT applicable in v1)**: those **MAY** follow the group's Marmot `message-retention` / NIP-40 expiry. A relay the node operates **MAY** honour that tag on a kind-`445` application message only. + +## Wire kinds + +This section restates the adopted Marmot Nostr shapes so a zkCoins implementer emits and accepts the same bytes. It does not invent kinds. Tag cardinality is exact: a missing, repeated, empty, or extra-valued required tag **MUST** be rejected. + +Transport byte fields that carry Marmot, MLS, or AEAD bytes use **standard base64 with padding** (RFC 4648 §4), not URL-safe base64, unless a field is defined as lowercase hex. A sender **MUST NOT** add an `encoding` tag. A receiver **MUST NOT** switch decoders from an `encoding` tag. + +Relay URL fields and `relay` / `relays` tag values follow Marmot's Nostr relay URL profile: valid UTF-8, at most 512 bytes, absolute `wss` or `ws` URL, host present, no username, password, or fragment. + +A receiver **MUST** verify a signed Nostr event's NIP-01 id and signature before treating `id`, `pubkey`, `created_at`, `kind`, `tags`, or `content` as authenticated transport metadata. + +### Kind 30443 — KeyPackage + +An addressable event, not deprecated NIP-EE kind `443`. Author = the account's `op_pubkey`. Content = standard-base64 `MLSMessage` whose wire format is `mls_key_package`. Private `init_key` material is never published. + +Required tags, each exactly once: + +| Tag | Value | +|---|---| +| `d` | 64-character lowercase hex of 32 random bytes — the publication-slot id | +| `mls_protocol_version` | `1.0` | +| `i` | lowercase hex KeyPackageRef | +| `mls_ciphersuite` | exactly one id-list tag | +| `mls_extensions` | exactly one id-list tag | +| `mls_proposals` | exactly one id-list tag | +| `app_components` | exactly one id-list tag | + +Id-list tags carry one or more `0x`-prefixed lowercase 16-bit ids, zero-padded to four hex digits, in a single tag array — for example `["app_components", "0x8001", "0x8009"]`. A producer **MUST NOT** split one list across repeated tags. A consumer **MUST** reject a repeated id-list name. + +`app_components` **MUST** include `0x8009` (`marmot.member.account-identity-proof.v2`). The required app component `marmot.transport.nostr.routing.v1` owns `nostr_group_id` and the group relay list once a group exists. + +The `d` slot id **MUST** be 32 CSPRNG bytes. It **MUST NOT** be derived from an account key, MLS leaf key, KeyPackageRef, or other identity material. Replacing the KeyPackage in a slot reuses the same `d`. + +Publish KeyPackages to the account's NIP-65 write-capable set. Fetch them from that same set. The `i` tag is a claim; the receiver **MUST** verify it against the decoded KeyPackage. + +### Kind 444 — Welcome rumor + +Kind `1059` gift wrap → kind `13` seal → **unsigned** kind `444` Welcome rumor. Gift-wrap recipient = the invitee's `op_pubkey`. Rumor content = standard-base64 `mls_welcome`. + +The rumor **MUST** carry: + +- exactly one `e` tag — lowercase hex event id of the consumed kind-`30443` KeyPackage; +- exactly one `relays` tag — one or more relay URLs where the new member fetches group messages. + +The rumor **MUST NOT** have a `sig` field (G-07). A receiver **MUST** reject a Welcome not addressed to the local `op_pubkey` (G-06). The `e` tag is a claim: the client **MUST** fetch and verify the referenced kind-`30443` event before treating the Welcome as joinable. + +Publish Welcomes to the invitee's kind-`10050` set. If that set is absent, fail closed (G-10). + +### Kind 445 — group message + +Exactly one `h` tag whose value is the lowercase hex 32-byte `nostr_group_id`. The only other permitted tag is an optional NIP-40 `expiration` on **application** messages. A commit or proposal **MUST NOT** carry `expiration`. No other tag is valid. + +The event `pubkey` **MUST** be a fresh ephemeral Nostr public key generated for that event, signed by the matching ephemeral key. It **MUST NOT** be `op` and **MUST NOT** be reused (G-04). + +```text +group_event_key = MLS-Exporter("marmot", "group-event", 32) +nonce = CSPRNG(12) +aad = "" +ciphertext = ChaCha20-Poly1305(group_event_key, nonce, mls_message_bytes, aad) +event.content = standard-base64(nonce || ciphertext) +``` + +`group_event_key` is scoped to one MLS group epoch. The exporter label/context pair is registered for this outer layer only. A receiver **MUST** reject content that is not valid standard base64 or that decodes to fewer than 28 bytes (12-byte nonce + 16-byte tag). + +`nostr_group_id` **MUST** be 32 CSPRNG bytes. It **MUST NOT** be derived from member keys or the MLS group id. It is the public routing id, not a secret. + +Dedup on the recovered MLS message id, never on the Nostr event id. Relay `created_at`, arrival order, and subscription order **MUST NOT** choose group state. + +Publish each signed kind-`445` event to every relay in the routing relay list. The first accepted acknowledgement does not cancel the remaining first-attempt obligation (Marmot publish lifecycle). + +Kinds `444`, `445`, `30443`, and `10002` are **inert in v1**; in v2, inert unless the kernel `group_chat` part is on. A kernel that does not run that part **MUST** ignore them for group-chat processing and **MUST NOT** mutate conversation state from them. The API feature only opens the REST door. + +## Membership + +Create a group → fetch a valid kind-`30443` KeyPackage for the invitee → gift-wrap a Welcome to that invitee → the invitee joins. Only admins change membership or metadata (Marmot `admin_pubkeys`). Removing a member is an MLS commit, not a new NIP-17 room. A member who leaves or is removed **MUST NOT** decrypt later epochs. + +## Forward secrecy and post-compromise security + +Forward secrecy and post-compromise security are inherited from MLS / Marmot. NIP-44, used by NIP-17 one-to-one messages, provides neither. This overlay **MUST NOT** be described as “NIP-17 groups”. Extra `p` tags on a kind-`14` rumor do not create an MLS group and remain invalid for the zkCoins one-to-one profile. + +## UI and payment safety + +The UI **MUST** label group messages as “Marmot / group”, never as NIP-17. That label **MUST** persist in conversation history. + +Payment actions **MUST NOT** be executable from group plaintext. A client **MUST NOT** auto-link payment URI schemes or pre-fill a payment form from group content. The same rule applies to mail-bridge SMTP bodies ([Mail bridge](/mail-bridge)). + +Group content **MUST NOT** be shown as authenticated NIP-17. There is no silent fallback among NIP-17, SMTP, and Marmot (G-08). + +## Interoperability + +A conforming v2 `group_chat` implementation **MUST** exchange application text with a current White Noise / Marmot client that speaks `marmot.transport.nostr` v1 at the pinned revision: create a group, invite via KeyPackage, send, receive, remove a member, and leave. Failure of that interop is a [V.13](/specification#v13-group-chat--marmot-overlay) fail, not a V.12 fail. + +V.13 is a **v2 only** conformance target — **not a v1 target**. V.12, A-to-Z, and payment conformance **MUST NOT** depend on this overlay. A **v1** release **MUST NOT** advertise `group_chat` and skips V.13. + +## REST + +Normative for protocol v2. A **v1** API **MUST NOT** serve these routes. A **v1** API **MUST NOT** advertise `group_chat` in `GET /v1/info` (it is not in the v1 closed `features` set). When protocol v2 activates, advertise `group_chat` only together with `wallet`, and only in the v2 info surface. When both are on and the kernel `group_chat` part is on, the API exposes the routes below. Crypto stays node-side. The SDK and app **MUST NOT** implement MLS; they call these routes. + +Auth is the same as `GET /v1/account/state`: a still-valid **ownership** pull session (`Authorization: Bearer `, [spec §5.1](/specification#51-capability-gated-pull), [§7.5](/specification#75-node-rest-api-normative)) — the v1 pull-session mechanism, used on v2 group routes. A GrantProof session **MUST** be rejected as HTTP `401` `{ "error": "unauthorized" }`. An expired, unknown, or `chan_bind`-mismatching token **MUST** be HTTP `410` `{ "error": "session_expired" }`. No SPEND material is involved. The kernel procedures take `session` + `chan_bind`; the subject comes from the server-side session, never from a client-supplied field. A v1 implementation still **MUST NOT** serve these routes. + +**Check order (normative).** For every `/v2/groups*` request the API **MUST** decide in this order and **MUST NOT** continue after the first failure: + +1. If this is a v1 API, or API `group_chat` is off, or `wallet` is off → `404 feature_disabled` without calling the kernel. A v1 API hitting `/v2/groups*` also answers 404 (no such v1 surface). +2. Then the ownership-session checks above → `401 unauthorized` or `410 session_expired`. +3. Then, if the kernel `group_chat` part is off → `404 feature_disabled` (G-09). + +Closed paths, JSON, fail-closed. The MLS group id **MUST NOT** appear on the public REST surface. + +`group_id` is a node-local stable id. It is not the secret MLS group id and is not necessarily `nostr_group_id`. + +| Method | Path | Body / Returns | +|---|---|---| +| `GET` | `/v2/groups` | `{ groups: [ { group_id, nostr_group_id, epoch, role, title } ] }` — local groups this node already holds; the API **MUST omit** `members` and `relays` | +| `POST` | `/v2/groups` | body `{ title }` → `{ group_id, nostr_group_id }` — create; `nostr_group_id` is lowercase hex of 32 CSPRNG bytes | +| `GET` | `/v2/groups/:group_id` | `{ group_id, nostr_group_id, epoch, role, title, members: [ { op_pubkey, role } ], relays: [] }` | +| `POST` | `/v2/groups/:group_id/messages` | body `{ content }` — a plaintext UTF-8 string → `{ message_id }` — the recovered MLS message id after the node processes the send | +| `GET` | `/v2/groups/:group_id/messages` | `{ messages: [ { message_id, sender_op_pubkey, content, created_at } ] }` — decrypted application texts the node has already processed; `created_at` **MUST** be the inner unsigned application event's `created_at`; list order **MUST** be ascending `(created_at, message_id)` | +| `POST` | `/v2/groups/:group_id/invites` | body `{ op_pubkey }` — invitee identity; the node fetches a valid kind `30443` and publishes a Welcome → `{ invited: true }` | +| `POST` | `/v2/groups/:group_id/members/remove` | body `{ op_pubkey }` — admin-only MLS remove commit → `{ removed: true }`; a non-admin **MUST** get `403` and **MUST NOT** mutate group state | +| `POST` | `/v2/groups/:group_id/leave` | `{ left: true }` | +| `POST` | `/v2/groups/keypackages` | body `{ d?: }` — omit `d` to create a new CSPRNG slot; if `d` is present it **MUST** be 64-char lowercase hex (empty string → `400 malformed_request`) and rotates that slot → `{ d, i }`; unknown `d` → `404 not_found` | + +Field encodings: `group_id` is an opaque UTF-8 string local to this node; `nostr_group_id`, `op_pubkey`, `d`, `i`, and `message_id` are lowercase hex; `epoch` is a JSON number (`u64`); `role` is the closed string `"admin"` or `"member"`; `title` and `content` are UTF-8 strings; `created_at` is a `u64` Unix timestamp taken from the inner unsigned application event; `relays` is a JSON array of relay URL strings. + +**Publication success.** For `messages`, `invites`, `members/remove`, `leave`, and `keypackages`, HTTP 200 **MUST** mean: the node durably persisted the local MLS/group mutation and made the required **first** publication attempt to every snapshotted target. It **MUST NOT** wait for a relay `OK`. Remaining first-attempt retries stay outstanding as Marmot transport work and **MUST NOT** undo the HTTP 200. A later relay failure is availability, not a REST error. + +An unknown `group_id` **MUST** return `404` `{ "error": "not_found", … }`. Invite and `members/remove` are admin-only. After the three request gates, the node **MUST** decide in this order and stop at the first failure: resolve `group_id` (`404 not_found`) → caller role (`403 not_group_admin` if not admin) → invitee kind-10050 (`409 invitee_not_ready`, before every network write). `leave` is a self-action and **MUST** succeed for both `admin` and `member`. The API maps `InviteGroupMember` / `RemoveGroupMember` / `LeaveGroup` `GroupAck.ok == true` to `{ "invited": true }`, `{ "removed": true }`, and `{ "left": true }` respectively. A successful RPC **MUST NOT** return `ok == false`; failure uses only the closed machine codes. + +## Negative controls + +Each case **MUST** reject with no conversation mutation: + +| # | Case | Expected | +|---|---|---| +| G-01 | extra recipient `p` on a kind-14 rumor used as a “group” | reject as outside the one-to-one NIP-17 profile; no Marmot group is created | +| G-02 | treating NIP-29 / kind 9 / kind 42 as E2E group chat | reject; those kinds are not this overlay | +| G-03 | deriving MLS leaf, HPKE-init, epoch, exporter, or `group_event_key` material from `op` / seed / `op_secret` / `nk` / `ivk` / `ovk` / SPEND / a §1 HKDF tag | reject the construction; leaf and HPKE-init MUST be CSPRNG; epoch / exporter / `group_event_key` MUST be MLS-derived | +| G-04 | kind 445 authored by `op_pubkey` or with a reused ephemeral key | reject the event | +| G-05 | kind 445 missing, duplicate, or `h` not 32-byte lowercase hex | reject the event | +| G-06 | Welcome not addressed to the local `op_pubkey` | reject before join | +| G-07 | signed kind-444 rumor | reject as a non-Marmot rumor | +| G-08 | silent fallback NIP-17 ↔ Marmot ↔ SMTP | abort; conversations stay separate | +| G-09 | advertising API `group_chat` without `wallet`, or calling `/v2/groups*` while the kernel `group_chat` part is off | do not advertise `group_chat` without `wallet`; after the session checks, every `/v2/groups*` request answers `404 feature_disabled` | +| G-10 | publishing a Welcome to a guessed relay when kind 10050 is absent | abort before every network write; HTTP `409` `{ "error": "invitee_not_ready" }` | + +## Privacy and trust + +On this overlay, group application plaintext is readable by the component that holds the account's MLS client credentials — the same node that holds `op`. With an own node this is under the user's control; a hosted provider holding the operational bundle can read groups and impersonate the account ([spec §6.6](/specification#66-threat-model-and-trust-configurations)). + +Kind `445` exposes `nostr_group_id` (random, not member-derived), a fresh ephemeral pubkey, timing, and volume. Welcomes look like NIP-59 DMs to the invitee. KeyPackages are public and authored by `op_pubkey`. The MLS group id stays off the public REST surface. + +The overlay holds no SPEND key and cannot move funds. Loss of MLS state, relay history, or application messages can lose group membership or chat history only; native zkCoins and mandatory NIP-17 remain separate. + +## See also + +- [Mail bridge](/mail-bridge) — the independent optional SMTP overlay +- [Lightning bridge](/lightning-bridge) — the independent optional swap overlay +- [Specification §6.1](/specification#61-components-and-responsibilities) — feature advertisement +- [Specification §7.3](/specification#73-nostr-event-kinds-normative) — Nostr kinds +- [Specification V.13](/specification#v13-group-chat--marmot-overlay) — overlay conformance diff --git a/docs/implementation-mandate.md b/docs/implementation-mandate.md index 6540887..309b926 100644 --- a/docs/implementation-mandate.md +++ b/docs/implementation-mandate.md @@ -21,9 +21,9 @@ The single normative source is **`docs/specification.md`** in the `zk-coins/docs The work is **not finished** when the node compiles. It is finished only when all four layers conform to the spec and interoperate end to end: 1. **`node`** (Rust) — build to the spec: the single `C` Plonky2 circuit ([spec §2.5](./specification.md), §1.7.9), the canonical hashing/serialization ([§1.7](./specification.md)), the Bitcoin inscription layer with NISSHAC half-aggregation and the first-occurrence scanner ([§3](./specification.md), §1.7.10), the Nostr relay + Blossom store + ZBE ([§4](./specification.md), §7.3, §7.4), the `kernel.v1` gRPC contract ([§7.8](./specification.md#78-kernel-rpc--the-internal-interface-normative)) — the kernel serves **no** REST and terminates no public request — and the publisher interface ([§3.4](./specification.md), §7.6). The public REST surface of [§5](./specification.md) / §7.5 and its capability gate belong to `api`, next. -2. **`api`** — build the sole public surface: the versioned `/v1/` REST contract ([spec §7.5](./specification.md#75-node-rest-api-normative)), the capability gate and challenge–response of [§5.1](./specification.md#51-capability-gated-pull) including the `chan_bind` binding to its **own** authoritative public host, the closed `features` set advertised on `GET /v1/info` ([§6.1](./specification.md#61-components-and-responsibilities)), NIP-05 name resolution and issuance under `wallet` ([§4.3](./specification.md#43-addressing-for-delivery)), and the `kernel.v1` gRPC client with the error mapping of [§7.8](./specification.md#78-kernel-rpc--the-internal-interface-normative). It owns a non-value-bearing database, **MUST NOT** read or write the kernel's store, and **MUST NOT** touch Bitcoin or prove. +2. **`api`** — build the sole public surface: the versioned `/v1/` REST contract ([spec §7.5](./specification.md#75-node-rest-api-normative)), the capability gate and challenge–response of [§5.1](./specification.md#51-capability-gated-pull) including the `chan_bind` binding to its **own** authoritative public host, the closed `features` set advertised on `GET /v1/info` ([§6.1](./specification.md#61-components-and-responsibilities)), NIP-05 name resolution and issuance under `wallet` ([§4.3](./specification.md#43-addressing-for-delivery)), and the `kernel.v1` gRPC client with the error mapping of [§7.8](./specification.md#78-kernel-rpc--the-internal-interface-normative). It owns a non-value-bearing database, **MUST NOT** read or write the kernel's store, and **MUST NOT** touch Bitcoin or prove. **Multi-network gateway (normative — [spec §7.5](./specification.md#75-node-rest-api-normative) *Multi-network projection*).** The `api` serves **testnet and mainnet in parallel** — both reachable at once, never a redeploy or cutover — by multiplexing over **one `kernel.v1` upstream per served network**, each upstream single-network with its `Info.network` naming exactly one network ([§7.8](./specification.md#78-kernel-rpc--the-internal-interface-normative)). The active network is the `/v1/{network}/…` path segment (`{network}` = `mainnet`, `testnet`, or `regtest` for development); a network-less `GET /v1/networks` discovers the **admitted** networks (admission, not live readiness; a configured-but-not-yet-admitted network is not listed and answers consensus-dependent paths with `503 network_not_ready`) and `GET /v1/{network}/info` is that network's §7.5 `/v1/info` body. An unserved network fails closed with `404 network_unavailable` (there is **no** default network and networks are never mixed); a served-but-not-ready upstream (including not-yet-admitted) still answers `GET /v1/{network}/info` with that network's `/v1/info` object (GetInfo always succeeds) and `GET /v1/{network}/health/ready` with the readiness body (`200 { ready: true }` or `503 { ready: false, reason }`); every other consensus-dependent path is `503 network_not_ready` (the specific reason is on the probe); before serving a network the api **MUST** verify the mounted upstream's `Info.network` equals it. Custody, addresses, coins, capability sessions, and the non-value-bearing database (including the idempotency store) are **strictly network-scoped**. The multiplexing is pure routing: the api still **MUST NOT** read or write any kernel store, touch Bitcoin, or prove. 3. **`sdk`** (TypeScript) — rebuild the **thin** typed client and account adapter against the **new** `/v1/` API: BIP-39/32 derivation, BIP-340 signing with the sign-to-contract tweak, the transition proving handshake (`/v1/tx` → `awaiting_signature` → `/sign`), and the capability/pull REST flows. The SDK is the reference for all client-side **crypto** (derivation, hashing, signing) — byte-equivalent at the hash and derivation level, verification-checked for signatures (§3) — and nothing more. It does **not** verify proofs, run scan loops, decrypt bundles, or talk to Nostr/Blossom directly; those live in the node (see "Thin-client rule" below). It talks only to the wallet's own node over REST. -4. **`app`** (Next.js) — rebuild on top of the rewritten SDK. Migrate the app's crypto path **off** its in-tree WASM onto the SDK's pure-TS primitives (the SDK exists to be that single implementation), and drive the full user journey (onboard, receive, send with the fee-less publisher hand-off, balance, history, disclosure links) through the SDK against a local node. The app stays thin too: it renders what its node serves and signs with keys it holds; it does not re-implement node-side verification or scanning. The one client-side-crypto exception the spec allows is the **stateless explorer** applying a bearer view secret to an already-encrypted blob ([spec §5](./specification.md)) — that is presentation, not wallet trust-reduction. +4. **`app`** (Next.js) — rebuild on top of the rewritten SDK. Migrate the app's crypto path **off** its in-tree WASM onto the SDK's pure-TS primitives (the SDK exists to be that single implementation), and drive the full user journey (onboard, receive, send with the fee-less publisher hand-off, balance, history, disclosure links) through the SDK against a local node. The app stays thin too: it renders what its node serves and signs with keys it holds; it does not re-implement node-side verification or scanning. The one client-side-crypto exception the spec allows is the **stateless explorer** applying a bearer view secret to an already-encrypted blob ([spec §5](./specification.md)) — that is presentation, not wallet trust-reduction. **Network selection (normative).** The app carries a runtime **network switch**: the active network (`mainnet`, `testnet`, or `regtest` for development) is part of the wallet and request context, the app targets the api under the selected network's `/v1/{network}/…` prefix (component 2 above), and it presents custody, addresses, balances, and history for the active network **only** (a mainnet balance never shows under testnet, and vice-versa). Switching the network re-scopes the whole session client-side; it never redeploys or reconfigures the api, which already serves every network in parallel. **Thin-client rule (hard, project-wide).** zkCoins follows the Bitcoin full-node model: the wallet trusts **its own** node. There is **no anti-node logic** in the wallet/SDK/app — no client-side proof verification, no scan loops, no view-key/spend-key consistency checks against a second node, no "node integrity" UI. Anything whose purpose is to reduce trust in the node belongs **node-side**, or the answer is self-hosting. This rule is stated verbatim in every repo's `CONTRIBUTING.md` and constrains where each piece of functionality lives. The spec's client-side verification language ([§2.3.3](./specification.md), [§4.4](./specification.md)) is always "the receiver **or its node, on its behalf**" — i.e. the node does it; the thin client delegates to the node it operates. @@ -34,11 +34,12 @@ Done = node ✓ **and** api ✓ **and** sdk ✓ **and** app ✓, each conformant A layer is done only when **all** of the following hold: - **Spec-conformant.** Every normative MUST/MUST NOT in `docs/specification.md` that applies to the layer is implemented. The conformance test vectors ([spec test-vector section](./specification.md#test-vectors-conformance-harness)) are generated and pinned (§4 below), and node and SDK each reproduce the values in their scope bit-for-bit (the SDK the hash- and derivation-level values; the `circuit_digest(C)` comes from the node's deterministic §1.7.9 build; the V.5/V.6 `signature` vectors validate by BIP-340 verification including the sign-to-contract tweak check, not byte equality). -- **NIP-17 wire-conformant** (node · SDK). Sent and received events match the [spec §7.3](./specification.md#73-nostr-event-kinds-normative) construction and pass the V.12 **wire** vectors: standard kinds 0/14/13/1059/10050, independent seal-and-wrap constructions per copy, plain-text kind-14 `content`, no zkCoins-specific message metadata, and no unnoticed NIP-04 fallback. Discovery searches the union of name-provider relay hints and configured profile/discovery/bootstrap relays: it passes with no `relays` attribute and when a hint yields kind 0 while a configured relay yields kind 10050. +- **NIP-17 wire-conformant** (node · SDK). Sent and received events match the [spec §7.3](./specification.md#73-nostr-event-kinds-normative) construction and pass the V.12 **wire** vectors: standard kinds 0/14/13/1059/10050, independent seal-and-wrap constructions per copy, plain-text kind-14 `content`, no zkCoins-specific message metadata, and no unnoticed NIP-04 fallback. Discovery searches the union of name-provider relay hints and configured profile/discovery/bootstrap relays: it passes with no `relays` attribute and when a hint yields kind 0 while a configured relay yields kind 10050. NIP-17 / V.12 remains mandatory. `group_chat` is a **v2 feature — NOT applicable in v1** — a v1 layer **MUST NOT** advertise it. +- **Group-chat overlay (v2 only — not a v1 DoD item).** `group_chat` is a **v2 feature — NOT applicable in v1**. V.13 is **not a v1 conformance target**. A **v1** release never lists `group_chat` in `GET /v1/info` and skips V.13. When protocol v2 activates it, a layer that advertises `group_chat` **MUST** pass [spec V.13](./specification.md#v13-group-chat--marmot-overlay): Marmot wire, negative controls G-01–G-10, and the White Noise interop matrix. V.13 is not an A-to-Z or V.12 blocker in v1. - **Name-survivable** (node · SDK). The build passes the V.12 **name-loss survivability** fixture: after an established contact's name resolution completes and answers *no* (a 404), messaging and payment against the pinned identity still succeed, the contact is neither deleted nor re-pinned, and that name stops being presented; a lookup that merely fails to complete is inconclusive and changes nothing (spec `M-16b`). - **A-to-Z tested end to end.** Beyond unit coverage there is a full-journey test that exercises a real flow across all four layers running locally: create three accounts (Alice, Bob; Carol for the token-standard-2 genesis), mint, pay (with a real publisher half-aggregating the transition nullifiers and inscribing the `AggregateStateNullifierV3` against a local/regtest Bitcoin), the recipient discovers + verifies + credits the coin, and a confirmation link renders. No mocks on the protocol path — real proofs, real inscriptions, real Nostr/Blossom transport. The A-to-Z suite is a **machine-evaluable pass predicate**, not a narrative. Fixtures (normative): Alice = the spec V.2-ext mnemonic at `account' = 0`, Bob = the same mnemonic at `account' = 1`, Carol = the same mnemonic at `account' = 2`; asset `USD-Demo`, `decimals = 2`, `issuance_version = 1`, supply `1_000_000_000` (the spec V.1/V.3 values); no publisher fee — v1 publishing is sponsored (spec D9), so no transition in this suite carries a fee coin; Bitcoin regtest with on-demand block mining; every confirmation wait is 6 mined blocks. Steps and assertions (each assertion is a hard pass/fail): - 1. Boot both nodes; `GET /v1/info` on each **equals** the pinned `circuit_digests` (`C`, `C_balance`) and bounds. + 1. Boot both nodes; `GET /v1/regtest/info` on each **equals** the pinned `circuit_digests` (`C`, `C_balance`) and bounds. 2. Alice mints the asset (`kind: "mint"`); job reaches `completed`; the mint nullifier is inscribed on regtest and reaches §3.10 `completed` after 6 blocks; Alice's balance for the asset equals `1_000_000_000`. 2b. **Carol** (a fresh account, `send_counter = 0`) genesis-mints the **second** asset `EUR-Demo` (`decimals = 2`, `issuance_version = 2`, `amount = 500_000_000`, `cap_total = 500_000_000`, `terms_salt = terms_salt_fixture`, spec V.4) with **explicit outputs to Alice** (token-standard-2 forbids self-credit in the creating transition, spec §6.5 (f)/(g)); the mint anchors; after Alice's clause-10 **receive** of the EUR-Demo output reaches `completed`, Alice's balances hold **two** assets, and every later assertion checks the **exact full balances map** ([Requirement 8](/requirements)); Bob's USD-Demo receive (steps 3–5) must leave EUR-Demo untouched. 3. Alice sends `250_000` to Bob (`kind: "send"`) via a real publisher, fee-lessly (spec §7.5 presence-matrix case (c)) — the request carries no `fee_address` and no fee output, and a request that carries either **MUST** be rejected; the publisher half-aggregates and inscribes an `AggregateStateNullifierV3`; the wallet-side `awaiting_signature` recomputation (spec §7.5) passes. @@ -49,8 +50,9 @@ A layer is done only when **all** of the following hold: 8. **Recovery control (Requirement 6):** destroy Bob's node state; restore from seed + regtest chain + the artefacts served by the network's seed-discoverable relays and blob stores; Bob's balance and coin set equal the pre-destruction state. 8b. **Seed-only recovery control (Requirement 13):** destroy Bob's node state **and** his own paired relay/blob store; the rebuilt node fetches and verifies the current signed Bootstrap Manifest from one reachable node base URL (under the pinned `bootstrap_pubkey`), then restores from the seed and the regtest chain (the nullifier accumulator and artefact verification still require Bitcoin, §4.5 steps 2/5) via the Bootstrap Manifest's `seed_relay`s (delivery events) and `blob_store`s (blob bytes); Bob's balance and coin set again equal the pre-destruction state — proving the two-plane recovery-discoverable overlap (spec §4.3/§4.5). 9. **Portability control (Requirement 10):** repoint Alice's wallet to a freshly synced second node by configuration only; balances and states identical; a send from the new node succeeds. - 10. **Attestation control (Requirement 9(b)):** Alice produces a balance attestation for USD-Demo via `POST /v1/attest/balance` (spec §7.5); a fresh verifier validates it — proof, host-side anchor checks, `nav_ceiling` canonical — against its **own** scan. - 11. **Grant control (Requirement 9(c)):** Alice issues a view grant scoped to USD-Demo via `POST /v1/grants`; the grantee pulls exactly the in-scope records via the §5.1 challenge–response and **cannot** pull EUR-Demo records (scope clamp, spec §5.1). + 10. **Attestation control (Requirement 9(b)):** Alice produces a balance attestation for USD-Demo via `POST /v1/regtest/attest/balance` (spec §7.5); a fresh verifier validates it — proof, host-side anchor checks, `nav_ceiling` canonical — against its **own** scan. + 11. **Grant control (Requirement 9(c)):** Alice issues a view grant scoped to USD-Demo via `POST /v1/regtest/grants`; the grantee pulls exactly the in-scope records via the §5.1 challenge–response and **cannot** pull EUR-Demo records (scope clamp, spec §5.1). + 12. **Reorg fail-stop (V.9 N-10):** force a ≥6-block regtest reorg that displaces a `completed` nullifier; each node **detects** the displacement, `GET /v1/regtest/health/ready` stops reporting ready (HTTP 503 `{ ready: false, reason: deep_reorg }`), and the node **MUST NOT** credit against the broken state (the suite asserts detection and fail-stop, not recovery — spec §3.9 / V.9 N-10). The suite passes iff **every** assertion above holds. No mocks on the protocol path — real proofs, real inscriptions, real Nostr/Blossom transport (unchanged rule above). ### App layer — identity and contacts (normative) {#app-layer-identity-and-contacts-normative} @@ -108,6 +110,7 @@ These are the decisions made to close the open implementation questions, recorde | D16 | **Spec wording aligned to the thin-client rule**: Requirement 4's verifier is "the receiver, or its node on its behalf"; §6.2/§6.3 attribute verification to the wallet's own node; foreign-node-only wallets get fail-closed discrepancy detection, not client-side proof verification. | §6.2, §6.3, §6.7 | The old §6.2/§6.3 text ("the wallet MUST verify against Bitcoin") contradicted this hard project rule (see "Thin-client rule" above and every repo's CONTRIBUTING.md). | | D17 | **`1798'` is the BIP-43 purpose index**, hardened, and the account root is `A = m/1798'/account'`. | §1.2 | Recorded because it never was: the index and the root shape are load-bearing for recovery — the dense account scan re-derives `Pk₀(n)` under exactly this path (§1.2) — and every conformance chain from V.2-ext down is pinned to it. Changing either after a tagged spec invalidates pinned vectors and every derived account. | | D18 | **`op` stays at the leaf `A/2'` in v1; `A/5'/j'` is reserved for op rotation and unused.** | §1.2, §6.6, §7.7 | Also recorded because it never was — `A/2'` was the one tree position with no decision behind it. Keeping the leaf preserves the pinned V.2-ext `op` value in an already-tagged spec. Reserving `A/5'` removes the permanent foreclosure: `op` is the only bundle member whose compromise is **active** (signing, impersonation, minting view grants), and without a reserved position the only remedy is abandoning the account and moving funds. The rotation procedure is not specified in v1. Rotation would not end a former operator's `ivk` view, which is fixed into the address through `nk_commit`. | +| D19 | **v2 Marmot/MLS group overlay — NOT applicable in v1.** Adopts Marmot `marmot.transport.nostr` v1 at git `4a2bc65f8db5` (kinds 444/445/30443/10002). The node holds MLS client credentials next to the operational bundle. Leaf and HPKE-init material are CSPRNG-generated; epoch secrets and `group_event_key` come from the MLS key schedule. None of it is derived from `op`, `op_secret`, `nk`, or SPEND. `op` MAY sign Marmot account-identity proofs and kind-30443 KeyPackage events only (it MUST NOT spend or derive MLS secrets). Not a v1 feature; V.13 is a v2 target; a v1 layer **MUST NOT** advertise `group_chat`. | §4.1, §4.3, §6.1, §6.3, §6.6, §7.3, §7.5, V.13, [Group chat](/group-chat) | Option A, no hybrid: NIP-17 stays the mandatory one-to-one profile; NIP-17 multi-`p`, NIP-29, NIP-04, and wallet-held MLS keys as a fake hosted-E2E exception are forbidden. Hosted `op` readability is the existing operational-bundle boundary. | If any of these is contradicted by a hard implementation constraint, change it **in the spec first**, regenerate affected vectors, then implement (a change to a frozen element is a version bump, [spec §1.7.8](./specification.md)). @@ -115,6 +118,8 @@ If any of these is contradicted by a hard implementation constraint, change it * The ordered, machine-followable sequence from first build to mainnet activation. Each step names its inputs, outputs, and a hard pass predicate; a step starts only when every earlier step's predicate holds. There is no human-gated step (project decision 2026-07-22; [Assurance Roadmap](./assurance.md)) — "PR merged" steps are repository mechanics, not review gates. +**Networks are served in parallel, not migrated in sequence.** The ordered steps below are each network's own readiness gates, not a testnet→mainnet cutover. Once a network passes its gate it is served **alongside** the others by the multi-network `api` (component 2 above), and the `app` selects the active network at runtime ([spec §3.6](./specification.md#36-chain-scanning) fixes each network's own `circuit_digest` / `activation_height` / params, so the networks never share protocol state). A network becomes discoverable (`GET /v1/networks`) once **admitted** — its own readiness gate has passed: `testnet` within step 7 (its A-to-Z journey passes on Signet), `mainnet` within step 9 (genesis + deployment complete, `GET /v1/mainnet/info` returns the pinned params). This ready-to-serve admission precedes step 10 activation (the first real transition), and an admitted network stays listed and reachable thereafter even during a transient not-ready state (spec §7.5 *Multi-network projection*); there is no switch to flip that replaces one network with another. + | # | Step | Inputs | Outputs | Pass predicate | |---|---|---|---|---| | 1 | Implement §1.7 primitives + §1.7.9 circuit build (node) | spec §1.7, §2 | node builds `C` and `C_balance` deterministically for `regtest`/`testnet`/`mainnet` tags | two clean-checkout builds of the same commit produce identical `circuit_digest(C)` and `circuit_digest(C_balance)` per network tag | @@ -123,10 +128,10 @@ The ordered, machine-followable sequence from first build to mainnet activation. | 4 | SDK primitive parity | pinned spec | `sdk/test/cross-rust/` suite | every V.7-parity-matrix "byte-equal" row reproduced bit-for-bit by the SDK; V.5/V.6/V.8 verification rows pass | | 4b | Full protocol stack | steps 1–4 | complete node (REST §7.5, publisher §7.6, Nostr/Blossom §7.3–§7.4, kernel §7.8), SDK, app | every §7.5/§7.6 endpoint serves its normative shape against a local instance; all unit/integration suites green | | 5 | Negative controls | pinned spec | V.9 suite in node (+SDK where in scope) | every V.9 signing/encoding case (N-01–N-08, N-11–N-17) rejects with the named reason, zero accepts; N-09/N-10 (regtest reorg controls, needing a live chain) run at step 6, not here; every V.11 negative control (NL-1–NL-7) rejects; the D-05 V.11 differential-test against the independent RFC-6962 reference passes | -| 6 | A-to-Z suite on regtest | steps 1–5 | the §3 machine pass predicate | every numbered assertion of the §3 A-to-Z suite holds, including the reorg controls (N-09: canonical replay converges — the accumulator value equals a fresh full rescan's; N-10: the node detects the displacement and `/health/ready` stops reporting ready), restore (Req 6), and portability (Req 10) controls | +| 6 | A-to-Z suite on regtest | steps 1–5 | the §3 machine pass predicate | every numbered assertion of the §3 A-to-Z suite holds, including the reorg controls (N-09: canonical replay converges — the accumulator value equals a fresh full rescan's; N-10: the node detects the displacement and `/v1/regtest/health/ready` stops reporting ready), restore (Req 6), and portability (Req 10) controls | | 7 | Public testnet | steps 1–6 green | testnet deployment (network tag `zkCoins/v1/testnet`, running against **Bitcoin Signet** — the maintained public test chain; the retired testnet3 MUST NOT be used); from this step the §7 wire formats are frozen ([spec §1.7.8](./specification.md#178-reference-instantiation-status-final-for-v1)) | [Assurance gate "Public testnet"](./assurance.md) — all listed criteria hold; the A-to-Z journey — minus the regtest-only controls (forced reorg N-09/N-10 and on-demand mining), which remain step 6 — passes against Signet with natural block cadence | | 8 | Mainnet gate check | steps 1–7 | gate checklist run | every step 1–7 output is green — a machine-evaluable checklist, not a human sign-off: V.4/V.8/V.11 vectors filled and byte-equal in the V.7 parity matrix (step 4); V.9 and V.11 negative controls all reject, zero accepts (step 5); the §3 A-to-Z suite's numbered assertions all hold on regtest (step 6) and on public testnet (step 7); the D-05 V.11 differential-test against the independent RFC-6962 reference passes; remediation Gates A–C all closed ([Paper-Conformance Remediation](./paper-conformance-remediation.md)); full detail in [Assurance gate "Real value (mainnet)"](./assurance.md) | -| 9 | Genesis + deployment | step 8; an operator-provisioned Bitcoin wallet funding the publisher's inscription fees (BTC UTXOs, spec §3.8); the §6.1 compose stack configuration | mainnet node config: pinned digests for tag `zkCoins/v1/mainnet`, `bitcoin_network = mainnet`, SECURITY.md present in the repo with a monitored contact (an operational duty, not a review gate); the operator (a) broadcasts the **genesis inscription** (the network's first zkCoins nullifier inscription — the mint of the genesis/first asset, spec §2.3.1), (b) once it confirms, reads its Bitcoin block height as the pinned **`activation_height`** (spec §3.6), (c) assembles `network-params.json` = the pinned tuple `{ network_tag, circuit_digest(C), circuit_digest(C_balance), activation_height, finality_confirmations = 6, bootstrap_pubkey }` in its canonical encoding (spec §3.6) and pins its SHA-256, and (d) publishes `network-params.json` so it is identical across all nodes | `GET /v1/info` on the deployed node returns `network = mainnet`, the pinned digests, `finality_confirmations = 6`, and the pinned `activation_height` (§3.6) — identical to the published network-parameter value; the published `network-params.json`'s SHA-256 matches the pinned digest; and `/health/ready` is `200` with `scanner_lag = 0` after initial sync | +| 9 | Genesis + deployment | step 8; an operator-provisioned Bitcoin wallet funding the publisher's inscription fees (BTC UTXOs, spec §3.8); the §6.1 compose stack configuration | mainnet node config: pinned digests for tag `zkCoins/v1/mainnet`, `bitcoin_network = mainnet`, SECURITY.md present in the repo with a monitored contact (an operational duty, not a review gate); the operator (a) broadcasts the **genesis inscription** (the network's first zkCoins nullifier inscription — the mint of the genesis/first asset, spec §2.3.1), (b) once it confirms, reads its Bitcoin block height as the pinned **`activation_height`** (spec §3.6), (c) assembles `network-params.json` = the pinned tuple `{ network_tag, circuit_digest(C), circuit_digest(C_balance), activation_height, finality_confirmations = 6, bootstrap_pubkey }` in its canonical encoding (spec §3.6) and pins its SHA-256, and (d) publishes `network-params.json` so it is identical across all nodes | `GET /v1/mainnet/info` on the deployed node returns `network = mainnet`, the pinned digests, `finality_confirmations = 6`, and the pinned `activation_height` (§3.6) — identical to the published network-parameter value; the published `network-params.json`'s SHA-256 matches the pinned digest; and `GET /v1/mainnet/health/ready` is HTTP `200` with `{ ready: true }` after initial sync; when the optional diagnostic `scanner_lag` is present it is `0` | | 10 | Mainnet activation | step 9 | first real-value issuance/transition | the first mainnet transition completes the full §3 journey (mint → send → receive → confirmation link) with every §3.10 state reaching `completed`; the v1 freeze has been in force since the vectors pin (step 3, [spec §1.7.8](./specification.md)); from this point breaking it would strand real value | Activation is deliberately unceremonial: mainnet "activation" is nothing more than the first real transition against the mainnet network tag after step 9 — there is no switch to flip and no coordinator. Rollback before step 10 is free (nothing carries value); after step 10 a version bump additionally carries real-value migration consequences. diff --git a/docs/keys-and-identities.md b/docs/keys-and-identities.md index 3b7c499..2c728ad 100644 --- a/docs/keys-and-identities.md +++ b/docs/keys-and-identities.md @@ -55,6 +55,8 @@ The tree exists because an account does five separable jobs, and only the first | **Void** spent coins | `A/3'` | also the node, so it can build proofs | | **Reproduce** a past proof's randomness | `A/4'` | also the node — `op_secret` keys the `nav_rand` derivation, so a rebuilt node can reopen any prior commitment | +Speak is the Nostr identity job. One-to-one chat stays NIP-17 on `op`. **v2 only, when `group_chat` is on:** the same `op` key **MAY** sign Marmot account-identity proofs and kind-30443 KeyPackage events; it still cannot spend and does not derive MLS secrets. MLS leaf secrets and HPKE init keys are **not** part of this branch and are **not present in v1**: the node generates them with a CSPRNG. Epoch secrets and `group_event_key` come from the MLS key schedule. None of that material can be rebuilt from the seed ([Group chat](/group-chat)). + The separations are **hardened**, which makes the tree a one-way street: a parent can compute its children, but a child can reach neither its parent nor its siblings. A node holding the view, speak, void, and reproduce branches therefore cannot compute the spend branch. It sees everything and can take nothing. ### Two recurring notions @@ -231,7 +233,7 @@ There is no per-coin signing key. One transition signature authorises the whole | `nk` | wallet and the wallet's **own** node | compute nullifiers | spend; it links the account's own spends, so it goes to no foreign node | | `ivk` | wallet and any node it delegates to | detect and decrypt incoming coins | spend | | `ovk` | same | recover outgoing plaintext | spend | -| `op` | the node | act as the Nostr identity, send and read NIP-17, sign profiles, relay lists, grants, and acknowledgements | spend, decrypt others' coins | +| `op` | the node | act as the Nostr identity, send and read NIP-17, sign profiles, relay lists, grants, and acknowledgements; **v2 only, when `group_chat` is on:** sign Marmot identity proofs and kind 30443 | spend, decrypt others' coins, derive MLS secrets | | `K_tx` | derived per coin, shareable | decrypt exactly one coin | spend, see any other coin | The **operational bundle** is `{ivk, ovk, op, nk, op_secret}` — what a wallet entrusts to its own node so the node can receive, prove, and serve on its behalf. None of it can spend. A foreign node receives a scoped view grant instead ([§6.2](/specification#62-wallet--node), [§5.2](/specification#52-view-grant)). @@ -256,6 +258,8 @@ Fresh for every output coin, so one per-coin capability discloses one coin and n | Name | Type | Origin | Rule | |---|---|---|---| +| MLS leaf secret / HPKE init key | secret material (**v2 only — not present in v1**) | CSPRNG, **not** the seed; held by the node that holds the operational bundle | that account's Marmot groups only; never derived from `op`, `op_secret`, `nk`, SPEND, or any §1 HKDF tag; lost on node switch unless the credential store is transferred ([Group chat](/group-chat)) | +| MLS epoch / exporter / `group_event_key` | secret material (**v2 only — not present in v1**) | MLS key schedule / `MLS-Exporter("marmot", "group-event", 32)` | same holder and reach as the leaf secret; never derived from `op`, seed, or §1 HKDF tags | | `npk_rand` | 32 unmodified CSPRNG bytes | drawn **fresh per proving attempt** | never derived deterministically, never reused; fail-closed if no CSPRNG | | `npk_commit` | SHA-256 digest, public | `H("zkCoins/v1/NpkCommit" ‖ next_pubkey ‖ npk_rand)` | the sixth `ProofData` field, recomputable by a thin wallet | | `nav_rand` | 32 bytes | `HKDF("zkCoins/v1/NavRand", op_secret ‖ u64-be(send_counter))` | deterministic, so a fresh node rebuilds any prior opening; never derived from `nav` | @@ -320,7 +324,7 @@ For an account-wide disclosure, prefer a scoped `zkgrant` over `zkavk` — not b | `addr_sig` | **`sk₀`** | that the address holder authorised exactly these payment fields, including the choice of `ivpk` and `op_pubkey` | | `name_sig` | **`sk₀`** | that the seed holder consented to this name | | `sig` (on an `Invoice`) | `op` | the per-issuance authorisation by the recipient's online key | -| Event signatures | `op` | kinds 0, 10050, 30421, and the NIP-59 kind-13 seal | +| Event signatures | `op` | kinds 0, 10050, 30421, and the NIP-59 kind-13 seal; **v2 only, when `group_chat` is on:** kinds 10002 and 30443 and Marmot account-identity proofs | | Gift-wrap signature | a **fresh one-time key**, per event | the kind-1059 wrapper — never `op`, which is what keeps the two copies unlinkable | | `op_sig` | `op` | that the recipient acknowledged a delivery (kind-1421 ACK) | | `op_signature` (in a `ViewGrant`) | `op` | the scope and window of a delegation | diff --git a/docs/lightning-bridge.md b/docs/lightning-bridge.md index 3294898..fd8af12 100644 --- a/docs/lightning-bridge.md +++ b/docs/lightning-bridge.md @@ -5,7 +5,7 @@ title: Lightning Bridge # Lightning Bridge :::info Optional operator service -The Lightning bridge is an **API-layer feature**, **off by default**, and **never part of the trustless core**. It is independent of the SMTP/email bridge, of the NIP-05 naming layer, and of mandatory NIP-17 messaging. An operator enables it as `lightning_bridge` in the closed `features` set of ([spec §6.1](/specification#61-components-and-responsibilities)); it touches no kernel part and adds no Nostr event. The bridge service adds no key custody — the operator never sees SPEND material through it — and can never touch settled balances; whether the same operator separately hosts operational bundles as a hosted-wallet provider is an independent trust decision ([spec §6.6](/specification#66-threat-model-and-trust-configurations)). +The Lightning bridge is an **API-layer feature**, **off by default**, and **never part of the trustless core**. It is independent of the SMTP/email bridge, of the NIP-05 naming layer, and of mandatory NIP-17 messaging. Group chat is a **v2 feature — NOT applicable in v1** ([Group chat](/group-chat)); these v1 bridges stay independent of it. Enabling or disabling a v1 bridge **MUST NOT** imply anything about v2 group chat. An operator enables it as `lightning_bridge` in the closed `features` set of ([spec §6.1](/specification#61-components-and-responsibilities)); it touches no kernel part and adds no Nostr event. The bridge service adds no key custody — the operator never sees SPEND material through it — and can never touch settled balances; whether the same operator separately hosts operational bundles as a hosted-wallet provider is an independent trust decision ([spec §6.6](/specification#66-threat-model-and-trust-configurations)). ::: ## What the bridge is @@ -82,3 +82,4 @@ Operating a Lightning bridge is a **custodial financial service** — the operat ## See also - [Mail bridge](/mail-bridge) — the independent optional operator service that makes the same NIP-05 identifier a working email address. +- [Group chat](/group-chat) — v2 Marmot/MLS group overlay — NOT applicable in v1. diff --git a/docs/mail-bridge.md b/docs/mail-bridge.md index ae1d5f7..7e12ec2 100644 --- a/docs/mail-bridge.md +++ b/docs/mail-bridge.md @@ -5,7 +5,7 @@ title: Mail Bridge # Mail Bridge :::info Optional operator service -The SMTP/email bridge is an **API-layer feature**, **off by default**, and independent of both the Lightning bridge and mandatory NIP-17 messaging. An operator enables it as `mail_bridge` in the closed `features` set of [spec §6.1](/specification#61-components-and-responsibilities). It moves messages, never zkCoins value. +The SMTP/email bridge is an **API-layer feature**, **off by default**, and independent of the Lightning bridge and of mandatory NIP-17 messaging. An operator enables it as `mail_bridge` in the closed `features` set of [spec §6.1](/specification#61-components-and-responsibilities). It moves messages, never zkCoins value. Group chat is a **v2 feature — NOT applicable in v1** ([Group chat](/group-chat)); these v1 bridges stay independent of it. Enabling or disabling a v1 bridge **MUST NOT** imply anything about v2 group chat. ::: ## One identifier, two independent message transports diff --git a/docs/paper-conformance-analysis.md b/docs/paper-conformance-analysis.md index a87d82d..2a25af6 100644 --- a/docs/paper-conformance-analysis.md +++ b/docs/paper-conformance-analysis.md @@ -27,7 +27,7 @@ Selected remediation proposal: [`zk-coins/docs#96`](https://github.com/zk-coins/ **Post-baseline architecture decision.** After the specification baseline reviewed here, [`research@f392fa0`](https://github.com/zk-coins/research/blob/f392fa0e4f55d68e6135e7eced15ef719118e545/zkcoins-design/ACCUMULATOR_SELF_PUBLISH.md) accepted on-chain half-aggregated state nullifiers, Bitcoin first occurrence and conditional NAV as the project plan of record. That later decision does not retroactively change this audit's findings against `docs@6816fc3`; it controls their selected remediation. `docs#96` is aligned to it, and the former full-batch-envelope alternative is rejected because it would retain the serialized `prev_root` writer. -**Register maintenance note (2026-07-22).** The audit text on this page is a pinned snapshot against `docs@6816fc3` and is not edited. The **register table below is the living deviation index** the [contribution rule](https://github.com/zk-coins/docs/blob/develop/CONTRIBUTING.md) points to: a **Status (current develop)** column tracks each row against the current normative specification, and rows **D-17–D-20** register the accepted v1 boundaries adopted on 2026-07-22. Where a row's *Current specification* column still describes the retired pre-#97 construction, the **status column is authoritative** for what holds today. Sections 5–12 below (perspectives, evidence tables, recommended status) are part of the pinned snapshot and are **not** release-governing: the v1 release gates are solely the [Paper-Conformance Remediation](/paper-conformance-remediation) Gates A–C, the [Assurance Roadmap](/assurance) gates, and the [implementation-mandate runbook](/implementation-mandate) — none of which contains an external-review step (project decision 2026-07-22). For rows graded AMBER in the historical columns whose Status (current develop) is CURRENT/DECIDED/ACCEPTED, the v1 disposition is the [Risks](/risks) verdict table plus the executable harness — the historical grade is not a v1 gate. In particular, the "obtain an external audit" / "subject to external audit" action items in the pinned Sections 8–10 (e.g. §8 required-actions, §9 P3 step 4, §10 recommended status) are **superseded** and are **not** v1 release gates — an autonomous implementer follows the Gates A–C / Assurance / runbook path, which has no external-audit step (project decision 2026-07-22). +**Register maintenance note (2026-07-22).** The text on this page is a pinned snapshot against `docs@6816fc3`; superseded v1-gate wording in §§5–10 MAY be corrected so it does not contradict living policy, while remaining historical phrasing stays superseded and is not release-governing. The **register table below is the living deviation index** the [contribution rule](https://github.com/zk-coins/docs/blob/develop/CONTRIBUTING.md) points to: a **Status (current develop)** column tracks each row against the current normative specification, and rows **D-17–D-20** register the accepted v1 boundaries adopted on 2026-07-22. Where a row's *Current specification* column still describes the retired pre-#97 construction, the **status column is authoritative** for what holds today. Sections 5–12 below (perspectives, evidence tables, recommended status) are part of the pinned snapshot and are **not** release-governing: the v1 release gates are solely the [Paper-Conformance Remediation](/paper-conformance-remediation) Gates A–C, the [Assurance Roadmap](/assurance) gates, and the [implementation-mandate runbook](/implementation-mandate) (project decision 2026-07-22). For rows graded AMBER in the historical columns whose Status (current develop) is CURRENT/DECIDED/ACCEPTED, the v1 disposition is the [Risks](/risks) verdict table plus the executable harness — the historical grade is not a v1 gate. An autonomous implementer follows the Gates A–C / Assurance / runbook path (project decision 2026-07-22). ## 0. Executive summary @@ -146,6 +146,7 @@ Legend: | D-19 | Publisher ash-chain linkage on reuse | Not addressed by the papers (no fee coin there). | A v1 hand-off discloses the pre-anchor `Pkᵢ` to the chosen publisher, which correlates that transition with the requester's transport identity and, on reuse, with its other hand-offs; under the deferred fee mechanism a reused publisher additionally links consecutive transitions via fee-coin `CoinProof` fields ([spec §3.8.1](/specification#38-fees-and-economics)). | ACCEPTED-V1 | None — accepted boundary, final for v1; rotation/self-publish removes the edge at no protocol cost ([Risks](/risks)). | ACCEPTED, final for v1 — voided by publisher rotation. | | D-20 | Fee pricing is off-protocol | Paper defers fee mechanics similarly. | No fee at all in v1: a publisher sponsors the inscription cost and is not reimbursed, so sustainability rests on a volunteer rather than on pricing. Self-publish is the universal escape ([spec §3.8](/specification#38-fees-and-economics)). | ACCEPTED-V1 | None — incentive verdict **holds under stated assumptions**, recorded in [Risks](/risks). | ACCEPTED, final for v1 — **sponsored**: no price to discover, sustainability rests on a volunteer, and self-publish is the universal escape. Never correctness-relevant. | | D-21 | Wallet-verifiable key rotation | not in the papers (wallet ≡ prover there) | a sixth hiding ProofData field `npk_commit = H("zkCoins/v1/NpkCommit" ‖ next_pubkey ‖ npk_rand)` (SHA-256), serialize 192 bytes; the thin wallet recomputes it and refuses to sign a substituted rotation key (fail-closed, [spec §2.1 clause 2](/specification#21-the-compliance-predicate), [§7.5](/specification#75-node-rest-api-normative)) | GREEN (extension) | None for v1 — wallet-native SHA-256, byte-pinned V.8 fixture. | CURRENT — an **extension** closing the hosted-prover rotation-capture ([Requirement 5](/requirements)); not a deviation from a paper construction, and no release gate beyond the executable harness (V.8 192-byte fixture). | +| D-22 | Marmot/MLS group-chat overlay | Not part of either source. | **v2 feature — NOT applicable in v1**. Adopts Marmot (`marmot.transport.nostr` v1 at git `4a2bc65f8db5`) over MLS RFC 9420. MLS leaf/HPKE-init secrets are CSPRNG-generated node-held operational state; epoch secrets and `group_event_key` come from the MLS key schedule; none are derived from `op`/`seed`/SPEND. A hosted `op` holder can read groups — the same operational-bundle boundary as NIP-17. Does not change NIP-17 one-to-one, V.12, A-to-Z, or payment conformance. | AMBER | None — no paper proof to inherit. Wire + G-01–G-10 + White Noise interop are V.13, a **v2 target** (required only when the v2 layer advertises `group_chat`). | CURRENT — application-layer addition (D-15 class), not a paper-security deviation ([Group chat](/group-chat), [spec §6.1](/specification#61-components-and-responsibilities), [§7.3](/specification#73-nostr-event-kinds-normative), [V.13](/specification#v13-group-chat--marmot-overlay)). | ## 5. Ten expert perspectives @@ -204,7 +205,7 @@ The research repository already contains a Plonky3 migration investigation, whil **Required actions:** 1. Decide whether v1 intentionally ships on deprecated Plonky2. *(Closed for v1: project decision 2026-07-22 pins `plonky2 = "1.1.0"`; see [Paper-Conformance Remediation](/paper-conformance-remediation) F-05.)* -2. If yes, vendor and maintain the full trusted code base and obtain an external audit. *(Audit is not a v1 release gate by the same decision.)* +2. If yes, vendor and maintain the full trusted code base. 3. Benchmark the exact `C` and `C_balance` circuits, not proxy circuits, and publish the node `build-report.md` ([Implementation Mandate §4](/implementation-mandate)) — still open; the artefact exists on the open node PR, but is not merged to `develop` and is not yet a benchmark publication. 4. Publish proof-size/proving-time distributions at maximum supported bounds. *(Verifier-data digests for `C` and `C_balance` per network are already pinned in V.4 from the reference implementation on open [`node#231`](https://github.com/zk-coins/node/pull/231); that branch is not merged to `develop`.)* @@ -335,7 +336,7 @@ The existing Apalache certificate targets [`docs@ed7fdece`](https://github.com/z Model checking also composes axiomatized cryptographic properties; it does not establish that Poseidon, Plonky2 recursion, non-native BIP-340 gadgets or the implementation satisfy those axioms. Liveness and indistinguishability properties have explicit scope reductions. -The project’s own [Assurance Roadmap](https://github.com/zk-coins/docs/blob/6816fc398ea35284e640ed8e0b326fa96880cf7d/docs/assurance.md) correctly requires security definitions, paper reductions, machine checking, implementation conformance and external audit before real value. +The project’s own [Assurance Roadmap](/assurance) lists security definitions, paper reductions, and machine checking as the verification staircase; for v1 the release-gating artefacts are the in-spec soundness argument and the executable harness. **Required actions:** @@ -383,7 +384,7 @@ The project’s own [Assurance Roadmap](https://github.com/zk-coins/docs/blob/68 - **Severity:** HIGH before mainnet - **Type:** proof-system lifecycle - **Impact:** Frozen dependencies accumulate compiler, security and ecosystem risk. Migrating later changes proof encodings, circuit digests and possibly field/hash assumptions. -- **Fix acceptance:** explicit v1 backend decision, maintained fork and audit, or pre-v1 migration. +- **Fix acceptance:** explicit v1 backend decision, maintained fork and audit, or pre-v1 migration. *(Closed for v1: project decision 2026-07-22 pins `plonky2 = "1.1.0"`; no fork audit is a v1 gate — see [Paper-Conformance Remediation](/paper-conformance-remediation) F-05.)* ### F-06 — DA independence and indefinite retention are asserted but not verifiable @@ -462,7 +463,6 @@ A deviation is not adequately supported merely because the specification contain 1. Complete publisher incentive analysis. 2. Define and test DA retention/retrievability. 3. Test independent failure domains rather than replica count alone. -4. Obtain an external cryptographic and implementation audit. ## 10. Release decision @@ -476,7 +476,7 @@ The current specification is suitable as a **research target design**. It is not Recommended public status: -> **zkCoins is a paper-inspired, core-compatible successor design. Its PCD/CSV foundation follows zkCoins and Shielded CSV, while its batching, nullifier accumulator, data-availability, recovery and publisher mechanisms are documented protocol deviations that remain subject to current-baseline proofs, implementation evidence and external audit.** +> **zkCoins is a paper-inspired, core-compatible successor design. Its PCD/CSV foundation follows zkCoins and Shielded CSV, while its batching, nullifier accumulator, data-availability, recovery and publisher mechanisms are documented protocol deviations that remain subject to current-baseline proofs and implementation evidence.** ## 11. Primary and project sources diff --git a/docs/paper-conformance-remediation.md b/docs/paper-conformance-remediation.md index 1d92834..4095192 100644 --- a/docs/paper-conformance-remediation.md +++ b/docs/paper-conformance-remediation.md @@ -41,7 +41,7 @@ A later coordinator lane may offer optional constant-size batching only if the d ## Findings and mandatory disposition -**F-01, F-02, F-04 and F-06 are resolved in the normative spec** by PR #97: the on-chain `(Pk_i, R_i)` state nullifier ([spec §3.1](/specification#31-the-on-chain-object), [§1.7.10](/specification#1710-half-aggregation-with-commitments-nisshac-normative)), first-occurrence rebuild from Bitcoin alone ([spec §3.6](/specification#36-chain-scanning)), predecessor-nullifier anchoring of every state-advancing transition including issuance ([spec §2.1](/specification#21-the-compliance-predicate), [§2.3.1](/specification#231-mint--issuance), [§3.10](/specification#310-transaction-states)), and replication reserved for private bearer data ([spec §4.6](/specification#46-data-availability)). What remains open is the executable-conformance evidence (canonical vectors, Gate B) and F-08's commit-pinned status matrix; F-05 and F-07 are closed by project decision (see the table below), and Gate C contains no external-review step (project decision 2026-07-22). The **selected disposition** column records the design decision each finding drove; the **release gate** column records what still gates mainnet. +**F-01, F-02, F-04 and F-06 are resolved in the normative spec** by PR #97: the on-chain `(Pk_i, R_i)` state nullifier ([spec §3.1](/specification#31-the-on-chain-object), [§1.7.10](/specification#1710-half-aggregation-with-commitments-nisshac-normative)), first-occurrence rebuild from Bitcoin alone ([spec §3.6](/specification#36-chain-scanning)), predecessor-nullifier anchoring of every state-advancing transition including issuance ([spec §2.1](/specification#21-the-compliance-predicate), [§2.3.1](/specification#231-mint--issuance), [§3.10](/specification#310-transaction-states)), and replication reserved for private bearer data ([spec §4.6](/specification#46-data-availability)). What remains open is the executable-conformance evidence (canonical vectors, Gate B) and F-08's commit-pinned status matrix; F-05 and F-07 are closed by project decision (see the table below). The **selected disposition** column records the design decision each finding drove; the **release gate** column records what still gates mainnet. | ID | Severity | Problem | Selected disposition | Release gate | |---|---:|---|---|---| @@ -216,7 +216,7 @@ Desirable post-v1 cryptographic write-ups (a quality goal, **not** a v1 release - privacy under the now chain-visible transaction count, rotating keys and commitments; - correctness of the concrete recursive circuit and implementation. -Model checking cannot replace primitive proofs; v1 ships without an implementation audit or gating formal proofs by project decision — the executable harness (Gate B) and the in-spec arguments are the v1 assurance basis ([Assurance Roadmap](/assurance)). +Model checking cannot replace primitive proofs; the executable harness (Gate B) and the in-spec arguments are the v1 assurance basis ([Assurance Roadmap](/assurance)). ## 8. Required negative controls @@ -276,13 +276,13 @@ PR #97 applied the following edits to the normative spec; this map remains the t ### Gate C — assurance - the specification's soundness summary ([spec §2.4](/specification#24-soundness-summary)) and security-properties summary ([spec §6.7](/specification#67-security-properties-summary)) exist, every clause reference they cite resolves, every Requirement 1–13 has a row, and D-17–D-19 appear in the [§6.7 precise privacy statement](/specification#67-security-properties-summary), D-16 is stated in [spec §3.9](/specification#39-finality-and-reorg-handling), and D-20 has its row in the [Risks](/risks) verdict table (machine-checkable link/row checks); -- **D-05** passes its release gate: the **V.11 differential-test** of the in-circuit RFC-6962 log-consistency/inclusion arithmetization against an independent reference ([spec §1.7.8](/specification#178-reference-instantiation-status-final-for-v1), V.11) — executed at the negative-controls / conformance step ([Implementation Mandate](/implementation-mandate) step 5/6) as part of the executable gate, not a separate human review — alongside the existing D-16/D-17–D-20 checks above; +- **D-05** passes its release gate: the **V.11 differential-test** of the in-circuit RFC-6962 log-consistency/inclusion arithmetization against an independent reference ([spec §1.7.8](/specification#178-reference-instantiation-status-final-for-v1), V.11) — executed at the negative-controls / conformance step ([Implementation Mandate](/implementation-mandate) step 5/6) as part of the executable gate — alongside the existing D-16/D-17–D-20 checks above; - **D-05 network-parameter agreement gate:** `network-params.json` is a byte-exact canonical artefact (spec §3.6); every node MUST load the pinned per-network `activation_height` and refuse readiness (`/health/ready` `503`) on mismatch; and a conformance test MUST confirm two independent nodes scanning the same tip from the **same** pinned `activation_height` produce the identical `(size, mth)` / `nav_root`, while a **differing** `activation_height` diverges them **whenever a valid nullifier is admitted in the interval between the two heights** — the conformance fixture **MUST** include at least one such nullifier so the divergence is observably guaranteed — the executable check for the parameter-agreement residual (D-05 (iv)). - the [Risks](/risks) verdict table has no open and no broken row; - the reference instantiation and backend are final and frozen for v1 ([spec §1.7.8](/specification#178-reference-instantiation-status-final-for-v1), [§1.7.9](/specification#179-proof-system-parameters-normative)); - a vulnerability disclosure process is published ([SECURITY.md](https://github.com/zk-coins/docs/blob/develop/SECURITY.md)). -By explicit project decision (2026-07-22) there is **no external audit, external proof review, or other human-gated step** in Gate C; the executable Gate-B evidence plus the in-spec arguments above are the v1 assurance basis. +The executable Gate-B evidence plus the in-spec arguments above are the v1 assurance basis. No real-value deployment proceeds until all three gates are complete. diff --git a/docs/proposals-kernel-v1-wire-completeness.md b/docs/proposals-kernel-v1-wire-completeness.md new file mode 100644 index 0000000..334799c --- /dev/null +++ b/docs/proposals-kernel-v1-wire-completeness.md @@ -0,0 +1,171 @@ +--- +title: "Proposal: v1 wire-contract completeness (§7.5 REST and kernel.v1 gRPC)" +--- + +# Proposal: v1 wire-contract completeness (§7.5 REST and kernel.v1 gRPC) + +:::warning Decision record — not yet normative +This page records places where the v1 wire contracts do not by themselves carry values the node +genuinely needs, and weighs how to resolve them. It is a decision record for review; it does **not** +change the normative spec. A follow-up specification PR — after reviewers choose a direction — makes the +actual edits to [§7.5](/specification#75-node-rest-api-normative) and +[§7.8](/specification#78-kernel-rpc--the-internal-interface-normative). The reference `api` and `node` +already carry working answers, which each item notes below — exactly why this is worth settling in the +spec rather than leaving as implementation drift. +::: + +## Why raise this now + +A transition value travels through two wire hops: a wallet or the SDK speaks **§7.5 REST** to the API +layer, and the API layer speaks **kernel.v1 gRPC** ([§7.8](/specification#78-kernel-rpc--the-internal-interface-normative)) +to the node. The SDK and third-party clients build against the §7.5 REST surface only — they never speak +kernel.v1 gRPC, which is operator-internal. Both contracts are normative, and the same two values are +under-specified on each. Because everything downstream builds against these contracts, reviewers should +settle the gaps at the source before more code accretes around the current work-arounds. + +## Gap 1 — `Pull` carries no ownership-vs-grant session discriminator (gRPC only) + +`Pull(PullRequest)` creates a session that later reads present by opaque `session` handle. The contract +draws a sharp line: `GetRecord`, `GetCoinProof`, and `SubscribeReceipts` admit **either** an ownership +**or** a grant session, but `GetAccountState` — which discloses the full account head — admits an +**ownership session only** and rejects a grant session. The `GetAccountState` row of the +[§7.8](/specification#78-kernel-rpc--the-internal-interface-normative) per-procedure error table maps a +grant session to `UNAUTHENTICATED` / `unauthorized` / `401`; [§5.1](/specification#51-capability-gated-pull) +gives the reason: "no full-state disclosure under a scoped grant". + +To enforce that at `GetAccountState` time — which carries only `{ session, chan_bind }` — the kernel must +already have recorded, at `Pull`, whether the session is ownership or grant. But the normative +`PullRequest { nonce, subject, resolved_scope, chan_bind }` carries no field stating which kind it is: an +ownership pull and a grant pull can arrive byte-identically, both with `resolved_scope` = `*` over the +same `subject`. The distinction lives in which capability the client proved to the API layer — an +`OwnershipProof` versus a `GrantProof` ([§5.1](/specification#51-capability-gated-pull)) — and the API +layer performs that entire gate, then tells the kernel only the resolved scope. + +- **How the node bridges it today.** The API layer passes the kind in an out-of-contract gRPC metadata + key, `x-zkcoins-session-authority` (values `ownership` | `grant`), and the kernel records it on the + session (the source carries a matching "Proto GAP" note). This value is genuinely out-of-band — unlike + Gap 2, it is not a proto field at all — so a client built only from the `.proto` cannot open a session + the kernel accepts at `GetAccountState`, and a reviewer reading only the contract cannot see that + the "ownership only" rule is satisfiable. + +## Gap 2 — the creator / genesis base pubkey is missing on both wire levels + +Minting binds an asset to its creator's base pubkey Pk₀ — `creator_pubkey` is a domain-separated hash +input to the on-chain `asset_id` ([§6.5](/specification#65-issuance--token-standards)), and +`GetTokenProvenance` must later return that same `creator_pubkey`. A **genesis receive** credits an +account with no prior state on the node. Both values are needed, and both wire contracts omit them: + +- **§7.5 REST.** The normative `POST /v1/tx` `TransitionRequest` + ([§7.5](/specification#75-node-rest-api-normative)) has no `genesis_pubkey`, and its `issuance` object + has no `creator_pubkey`. +- **kernel.v1 gRPC.** The normative `Issuance` and `TransitionRequest` messages + ([§7.8](/specification#78-kernel-rpc--the-internal-interface-normative)) omit the same two fields. + +**How the implementations bridge it today.** The node's `proto/kernel/v1/kernel.proto` already carries +`Issuance.creator_pubkey = 7` and `TransitionRequest.genesis_pubkey = 12`, and the reference `api` +crate's wallet-facing JSON already carries `issuance.creator_pubkey` (required) and `genesis_pubkey` +(optional) — its source even cites "(§7.5)" for a field §7.5 does not define. So both the REST body and +the gRPC message have silently diverged from the normative text in the same two fields. + +**Can the node derive the value instead of carrying it?** Partly, and unevenly — which matters for the +options below: + +- The compliance circuit binds `creator_pubkey` to the minting account via + `address(creator_pubkey, nk_commit) == owner` (all mints), and — for token-standard-2 only — + additionally to `current_pubkey`. For token-standard-1, and whenever the minting account has advanced + (`send_counter > 0`, so `current_pubkey ≠ Pk₀`), the wire contract does not make Pk₀ recoverable from + the account head. The reference node does persist Pk₀ per account, so it *can* look it up for an + already-registered account; the contract does not guarantee that. +- A genesis receive's recipient has no prior account state on the node, so no scheme that reads existing + account state can look up its Pk₀. (The node did see that Pk₀ once, though — in the `OwnershipProof` at + bootstrap entrust; see the derive option below.) + +## Options and trade-offs + +The two gaps are independent; reviewers can resolve each one differently. The options below lay out the +choice rather than foreclose it. + +**Gap 1 (session discriminator).** + +- *Add a typed field to `PullRequest`* — closed string `authority` (`ownership` | `grant`), matching the + proto's own house style ("closed string value sets stay as strings … not as enums", as + `TransitionRequest.kind` and `Job.status` already are). Makes `Pull` drivable from the `.proto`. +- *Normatively document the `x-zkcoins-session-authority` metadata key* as part of the kernel.v1 + transport. Smaller edit, but the `.proto` stays non-self-sufficient — a client built from the `.proto` + alone still needs out-of-band knowledge. + +**Gap 2 (creator / genesis pubkey).** + +- *Add the fields to both §7.5 and §7.8* (the shape both implementations already use). Directly matches + reality; the trade-off is that it edits the frozen wire text (see the freeze analysis below). +- *Derive the value node-side instead of carrying it.* The node already receives an account's Pk₀ in the + `OwnershipProof` at `POST /v1/bootstrap/entrust`, and it requires an active operational bundle for + **every** transition — including a genesis receive — so a genesis recipient has necessarily entrusted, + and the node has already seen its Pk₀ once. A design that captured and persisted Pk₀ at entrust could + make **both** `creator_pubkey` and `genesis_pubkey` derivable without a per-transition field. The + catch: the API handler verifies the `OwnershipProof`, but the `EntrustRequest` it then builds carries + no Pk₀ field, so the verified key never reaches the kernel. This route therefore trades two + per-transition fields for a different additive change (Pk₀ into the entrust path) and couples issuance + and receive to persisted bootstrap state. +- *Defer to kernel.v2 / a §7.5 `/v2/`* — keep the frozen text pristine (see below). +- *Document the current divergence as deliberate* — ratify the spec-vs-implementation drift in place + rather than close it. This is **not** a paper-conformance deviation in the `CONTRIBUTING.md` sense + (that rule governs departures from the two source papers — zkCoins registers each such departure, with + its rationale and release gate, in the Paper-Deviation Analysis and Paper-Conformance Remediation, adds + a security argument only where a load-bearing boundary moves, and opens a Risks entry and an issue only + for an open contradiction); + it is a text-vs-implementation gap, so it needs a spec statement making the divergence intentional, not + a paper-deviation register entry. + +**Recommendation (a preference, not a foreclosure):** a single additive amendment that adds `authority` +to `PullRequest` and the two pubkey fields to both §7.5 and §7.8 — because the values are needed, the two +pubkey fields already exist in this shape in both implementations (only `authority` is a genuine new +addition), and (below) the freeze rule permits it pre-step-7. The node-side-derive route is a real +alternative for the pubkeys, but it trades these fields for a different additive change to the entrust +path, so it does not avoid amending a wire contract. + +## Why the additive amendment fits the v1 freeze + +[§1.7.8](/specification#178-reference-instantiation-status-final-for-v1) freezes the wire formats only +**from runbook step 7 (public testnet)**. It states, verbatim, that **between step 3 and step 7** "an +addition to the §7 wire formats that touches **neither** a circuit element **nor** a pinned vector **nor** +a digest is **not** a new protocol version; it **MUST** be introduced by a specification PR that states +why the addition is required" — and it names the additive `GET /v1/token//provenance` read as +"exactly such an addition". That rule spans all of §7, so it covers the §7.5 REST body and the §7.8 gRPC +message alike. Each field here qualifies: + +- **`authority`** is a pull-session access field with no circuit involvement. +- **`creator_pubkey`** is *already* a bound circuit input (it hashes into `asset_id`); the new wire field + only transports a value the circuit already consumes. +- **`genesis_pubkey`** supplies the genesis account's initial `txn_pubkey`, a value the account model + already binds; the wire field transports it rather than introducing a new circuit input. + +None of the three changes a circuit element, a pinned vector, or a digest, and none moves a trust +boundary — the API layer still performs the entire [§5.1](/specification#51-capability-gated-pull) +capability gate. The precondition is that the network is **still before runbook step 7**; after that +point a §7 change is a new protocol version and this must instead be a `/v2/` contract. Given a +pre-step-7, green-field v1, an additive amendment matches the spec's own mechanism; deferring to v2 keeps +the frozen text pristine but ships a v1 whose published contracts cannot carry values the node needs. + +## Spec impact when adopted (v1, pre-step-7) + +A follow-up specification PR would, per the §1.7.8 between-steps rule, state why each addition is required +(this page) and then: + +- **[§7.8](/specification#78-kernel-rpc--the-internal-interface-normative) (kernel.v1 gRPC).** Add + `PullRequest.authority` (closed string `ownership` | `grant`); its absence or any other value is + `INVALID_ARGUMENT` / `malformed_request` / `400` (the mapping `Pull` already uses). **Ratify** the two + pubkey fields already present in the node proto — `Issuance.creator_pubkey = 7`, + `TransitionRequest.genesis_pubkey = 12` — with their presence rules (`creator_pubkey` required for a + mint; `genesis_pubkey` required for a genesis receive, `INVALID_ARGUMENT` / `malformed_request` / `400` + otherwise), and note that `authority` retires the `x-zkcoins-session-authority` metadata key. So this + ratifies two existing proto fields and introduces one new one. +- **[§7.5](/specification#75-node-rest-api-normative) (REST).** Document the matching JSON fields + (`issuance.creator_pubkey`, `TransitionRequest.genesis_pubkey`) the reference `api` already accepts, + with the same presence rules and error mapping. +- **A small node fix** to track with the amendment: map the genesis-receive presence-rule violation to + `INVALID_ARGUMENT` instead of today's `internal_error`. + +It touches no circuit element, pinned vector, or digest, so the amendment rebuilds no lineage. Until +reviewers choose a direction, the spec text stays unchanged and the implementations keep their current +fields. diff --git a/docs/requirements.md b/docs/requirements.md index 3a20d1d..ffd8b2c 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -48,11 +48,11 @@ A wallet — the holder of the seed — can switch nodes at any time and can use ### 11. Standard identity and messaging -An account's Nostr identity key is one of the keys [Requirement 6](#6-recovery) derives from the seed, and its payment identifier is fixed by those keys alone. Human messaging is standard Nostr NIP-17 carried by that same seed-derived Nostr identity key; it must interoperate bidirectionally with ordinary Nostr clients and must never require a zkCoins-specific message kind, endpoint, profile field, or capability marker. +An account's Nostr identity key is one of the keys [Requirement 6](#6-recovery) derives from the seed, and its payment identifier is fixed by those keys alone. Human one-to-one messaging is standard Nostr NIP-17 carried by that same seed-derived Nostr identity key; it must interoperate bidirectionally with ordinary Nostr clients and must never require a zkCoins-specific message kind, endpoint, profile field, or capability marker. NIP-17 remains the mandatory one-to-one profile in v1. Marmot/MLS group chat (`group_chat`, [Group chat](/group-chat)) is a **v2 feature — NOT applicable in v1**. The app and API layers give every account they serve an email-style NIP-05 name such as `alice@example.com`, resolve names, and publish the signed payment object bound to the name, so the account is reachable and payable by it. The node kernel works from public keys. -An account has one name in force at a time, and the account holder attests it with the wallet-only spend key, so a consumer can establish that the seed holder — not merely whoever holds the node-held Nostr key — put that name on that identity. Every app verifies that attestation before it accepts a name for a counterparty. A name is not derived from the seed, enters no value-bearing structure, and carries no payment authority; it can be replaced without affecting keys, funds, or an established contact, and losing it costs reachability under that name and nothing else. Lightning/LNURL and SMTP/email bridges are independent, optional operator services and are prerequisites for nothing. +An account has one name in force at a time, and the account holder attests it with the wallet-only spend key, so a consumer can establish that the seed holder — not merely whoever holds the node-held Nostr key — put that name on that identity. Every app verifies that attestation before it accepts a name for a counterparty. A name is not derived from the seed, enters no value-bearing structure, and carries no payment authority; it can be replaced without affecting keys, funds, or an established contact, and losing it costs reachability under that name and nothing else. Lightning/LNURL and SMTP/email remain independent, optional operator services in v1 and are prerequisites for nothing. Marmot/MLS group chat is a **v2 feature — NOT applicable in v1**, not a v1-optional service. *(That end-user applications present names rather than raw identifiers is an application requirement, stated for the `app` layer in the [Implementation Mandate](/implementation-mandate#app-layer-identity-and-contacts-normative).)* @@ -66,6 +66,8 @@ Access revocation withdraws authorization but erases nothing: it permanently cea A node is data-retentive by default: everything it can capture, it captures and keeps. At the moment it accepts an incoming coin it takes in the coin's complete token provenance — its `asset_terms`, when the bundle carries them — alongside the bundle itself, so the token remains alive and transferable even if its issuer later disappears ([spec §4.6](/specification#46-data-availability), [spec §4.8](/specification#48-durability--the-store-everything-invariant)). Because losing this data can lose funds, the node operator must maintain a real-time, restorable backup of the node's value-bearing PostgreSQL store and its blob store at all times; the backup mechanism itself is a deployment and hosting concern, deliberately out of scope for the node software, but operating a node without one violates this requirement ([spec §4.8](/specification#48-durability--the-store-everything-invariant) *Operator durability duty*). +**Exception — Marmot application messages only (v2 only — NOT applicable in v1).** When protocol v2 has activated `group_chat`, Marmot MLS **application** messages **MAY** follow the group's Marmot `message-retention` / NIP-40 expiry ([Group chat](/group-chat)). This exception does **not** apply in v1, and does **not** apply to value-bearing artefacts (`CoinProof` bundles, delivery events, `SelfDeliveryRecordV1`, blobs, `asset_terms`), to stored NIP-17 messages, or to Marmot commits and proposals, which never expire. A relay the node operates **MAY** honour that NIP-40 tag on a kind-445 application message only. + ### 13. Recovery availability from the seed alone A wallet that has lost everything except its seed can fully recover its state — every value-bearing artefact addressed to it — as long as it can reach one node to fetch and verify the current signed Bootstrap Manifest (a reachable node base URL) and at least one holder of each plane of that data is still live and reachable: its own node's backup, a self-hosted relay and blob store, or a seed-discoverable relay (for the delivery event) together with a seed-discoverable blob store (for the blob). This is guaranteed by the recovery-discoverable overlap invariant — the publishing node **must** publish every delivery event to at least one of the network's seed-discoverable relays **and** store every value-bearing blob in at least one of the network's seed-discoverable blob stores, so a seed-only scan of that set finds both the locator and the bytes of everything ever addressed to the account, as long as manifest rotation has preserved recovery-discoverability ([spec §4.3](/specification#43-addressing-for-delivery)). Every value-bearing artefact is additionally published, encrypted, to the Nostr relay plane (its event) and the Blossom blob plane (its blob) as a second redundancy layer, and any participant may run and sync their own relay and blob store rather than depend on any one node ([spec §4.6](/specification#46-data-availability)). A token survives the loss of its issuer: it remains transferable from its own `CoinProof` bundle and the chain alone, and its display terms are openly resolvable by `asset_id` from any holder that has retained them, as long as ≥1 such holder exists ([spec §4.5](/specification#45-recovery), [spec §4.10](/specification#410-responsibility-boundaries-and-the-availability-model-normative)). diff --git a/docs/risks.md b/docs/risks.md index f960c39..65a3fb3 100644 --- a/docs/risks.md +++ b/docs/risks.md @@ -35,7 +35,10 @@ Every incentive/residual verdict for v1, closed per the [Assurance Roadmap](/ass | Manifest `blob_store` open recovery-overlap upload (Sybil spam) | **accepted operational residual** | per-source admission control bounds but cannot cryptographically prevent Sybil-rotated spam — the same open-acceptance surface as an open `seed_relay`; operator admission policy | | Operator backup duty enforced operationally, not by protocol | **holds under stated assumptions** | operator MUST keep a real-time backup of the PostgreSQL database and blob store; deliberately an out-of-repo hosting concern | | Unobservable total supply (token standard 1) | **accepted v1 boundary** | documented issuer-trust (register D-13); token standard 2 provides the auditable cap | -| Carrying real Bitcoin requires a bridge | **holds** (out of core scope) | bridges are explicit, off-by-default operator extensions | +| Hosted `op` holder reads Marmot groups | **holds under stated assumptions** when v2 activates `group_chat` (same operational-bundle boundary as NIP-17, [spec §6.6](/specification#66-threat-model-and-trust-configurations)); **does not apply in v1** | **v2 feature — NOT applicable in v1**; self-host is the private path | +| Lost MLS state is not lost funds | **holds** when v2 activates `group_chat`; **does not apply in v1** | MLS credentials are non-value-bearing; Requirement 12's sole carve-out is Marmot application-message expiry **(v2 only — NOT applicable in v1)** | +| Kind 445 metadata (`h` tag, volume) | **accepted operational residual** when v2 activates `group_chat`; **does not apply in v1** | `nostr_group_id` is random, not member-derived; ephemeral pubkey is fresh; plaintext stays under the group-event key | +| Carrying real Bitcoin | **holds** (out of this protocol catalog) | native assets settle on Bitcoin L1 and need no bridge; 1:1 BTC is specified in [`zk-BTC/zkbtc`](https://github.com/zk-BTC/zkbtc/blob/main/spec/ZKBTC_TOKEN.md) (effectively-trustless claim: §1.1.1), outside this document's token-standard catalog | | No smart contracts | **holds** (by scope) | deliberate non-goal of v1 | | Regulatory uncertainty | **n/a — not a protocol mechanism** | environment risk, catalogued for operators | @@ -195,13 +198,37 @@ This anchoring closes the mint-fork: two mints advancing from the same prior sta **Mitigation:** Holders trust the creator as they would any single-issuer asset. Protocol-enforced, auditable supply is available in `IssuanceTerms_v2`, which bounds total emission with an in-circuit `cap_total` ([spec §6.5](/specification#65-issuance--token-standards)). -## Carrying real Bitcoin requires a bridge +## Hosted operator can read Marmot groups + +**Risk: When protocol v2 activates `group_chat`, a hosted provider who holds `op` can decrypt group application messages and impersonate the account in groups.** This risk **does not apply in v1** (`group_chat` is a **v2 feature — NOT applicable in v1**). + +When v2 `group_chat` is on, the node that holds the operational bundle also holds the MLS client credential store ([Group chat](/group-chat), [spec §6.6](/specification#66-threat-model-and-trust-configurations)). That is the existing operational-bundle boundary, not a new one. MLS leaf, HPKE, and epoch secrets are not derived from `op`; the holder of `op` still reads groups because it holds those credentials next to the bundle. + +**Mitigation:** Self-host. Wallet-held MLS keys are not a hosted end-to-end exception and **MUST NOT** be presented as one. + +## Lost MLS state is not lost funds + +**Risk: When protocol v2 activates `group_chat`, switching nodes or losing the MLS credential store drops group membership and history.** This risk **does not apply in v1**. + +MLS state is non-value-bearing operational state. It is not seed- or chain-derived and is listed as a [spec §6.3](/specification#63-node-portability-and-multi-node-operation) residual. [Requirement 12](/requirements#12-data-permanence) still applies; its sole carve-out is Marmot application-message expiry when `group_chat` is on **(v2 only — NOT applicable in v1)**. Lost credentials are a portability residual, not a retention rule. + +**Mitigation:** Transfer the credential store when switching nodes if group continuity is required. Losing it cannot spend, forge, or destroy coins. + +## Kind 445 metadata + +**Risk: When protocol v2 activates `group_chat`, relays see `nostr_group_id`, a fresh ephemeral pubkey, timing, and volume on every group message.** This residual **does not apply in v1**. + +Kind `445` carries a public `h` tag equal to the 32-byte `nostr_group_id`. That id is random and is not derived from member keys or the MLS group id, so it does not link members across groups. Welcomes look like NIP-59 DMs to the invitee. KeyPackages are public and authored by `op_pubkey`. + +**Mitigation:** This is transport metadata, not plaintext. The residual is volume and group-routing visibility at the relays on the routing list. + +## Carrying real Bitcoin **Risk: The protocol moves shielded coins, not on-chain BTC.** -zkCoins settles its own coins on Bitcoin L1 for ordering and anchoring, but a coin in the system is not itself on-chain BTC — permissionless native assets are the protocol's value model. Carrying real Bitcoin value in and out requires a **bridge**, which is outside the protocol; a fully trustless bridge is an open research problem (an N-of-M federation at launch, a 1-of-n BitVM bridge as the target — see the [bridge research](https://github.com/zk-coins/research/blob/develop/zkcoins-design/BITVM_BRIDGE.md)). +zkCoins settles its own coins on Bitcoin L1 for ordering and anchoring, but a coin in the system is not itself on-chain BTC — permissionless native assets are the protocol's value model. One-to-one BTC backing is specified in [`zk-BTC/zkbtc`](https://github.com/zk-BTC/zkbtc/blob/main/spec/ZKBTC_TOKEN.md), outside this protocol catalog. zkBTC is **effectively trustless** in that spec's §1.1.1 sense: the holder registers as an operator **before the first mint** in a cumulative, growth-only set and **must not sign a malicious graph**; every new vault is N-of-N over that full set; a later Sybil club cannot omit them; operators cannot steal under 1-of-N setup honesty, an honest in-window challenger, **and sound circuit/graph crypto** (a critical soundness bug is the theft case); the holder can exit themselves. A private-fork mint (Attack A) is independent of that join and is a Bitcoin-class residual unless a designated gatekeeper honestly performs R-04. A gatekeeper is optional and only gates new mints. It is not a federation. -**Mitigation:** The protocol's trustless guarantees do not depend on a bridge, and native assets need none. A bridge is a separate, out-of-protocol component: a federation can censor or, at its threshold, collude, while the BitVM target needs only 1-of-n honesty (funds are burned rather than stolen if all operators cheat). +**Mitigation:** The protocol's trustless guarantees for native assets do not depend on a BTC peg. For one-to-one BTC backing, follow [`zk-BTC/zkbtc`](https://github.com/zk-BTC/zkbtc/blob/main/spec/ZKBTC_TOKEN.md) §1.1.1: join the operator set before the first mint to close the Sybil-pool drain; the remaining assumptions (challenger, sound crypto, Attack A / `D_mint`) stay those of that specification. ## No smart contracts diff --git a/docs/specification.md b/docs/specification.md index 90c5160..c98c928 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -159,8 +159,8 @@ Where each requirement is satisfied: | **8 · Multi-asset** | §1.4 (`asset_id`), §1.5 (per-asset balances), §2 (per-asset conservation), §6 (issuance) | | **9 · Selective disclosure** | §5 (three opt-in tiers — per-transaction §5.6, balance attestation §5.7, full-history view grant §5.8; each verifiable against Bitcoin, rendered by a self-hostable explorer) | | **10 · Node portability** | §1.2/§4.5/§4.8 (value-bearing wallet state is seed-/chain-derived or content-addressed, permanently retained, fetchable, and independently verifiable; none is tied to one node), §6 (switch / multi-node and explicit non-value portability residuals) | -| **11 · Standard identity and messaging** | §1.2 (identity keys seed-derived), §4.1/§4.3 (the app and API layers give every account they serve one name and a signed kind-0 payment object; the holder attests the name with `sk₀` via `name_sig`; the name is replaceable and survivable on loss; contacts keyed by `op_pubkey`), §7.3/V.12 (mandatory NIP-17/kind-10050 and external-client interoperability), Lightning/mail bridge pages (independently optional) | -| **12 · Data Permanence** | §4.8 (never delete received data — store everything indefinitely and unconditionally; no drop, no expiry, no pruning, no supersession clean-up); §4.2/§4.5 (sender and every holder retain their copy forever; recovery therefore needs no central backup custodian); §7.4 (blob store is append-only — no `DELETE`) | +| **11 · Standard identity and messaging** | §1.2 (identity keys seed-derived), §4.1/§4.3 (the app and API layers give every account they serve one name and a signed kind-0 payment object; the holder attests the name with `sk₀` via `name_sig`; the name is replaceable and survivable on loss; contacts keyed by `op_pubkey`), §7.3/V.12 (mandatory NIP-17/kind-10050 and external-client interoperability), Lightning/mail bridge pages (independently optional). The [group-chat](/group-chat) overlay is a **v2 feature — NOT applicable in v1**. | +| **12 · Data Permanence** | §4.8 (never delete received data — store everything indefinitely and unconditionally; no drop, no expiry, no pruning, no supersession clean-up; the Marmot application-message carve-out is a **v2** exception when that overlay is on — [Group chat](/group-chat)); §4.2/§4.5 (sender and every holder retain their copy forever; recovery therefore needs no central backup custodian); §7.4 (blob store is append-only — no `DELETE`) | | **13 · Recovery availability from the seed alone** | §4.3 (recovery-discoverable overlap — every delivery event reaches ≥1 network `seed_relay`, every blob ≥1 network `blob_store`); §4.5 (seed-only reconstruction complete on both planes after loss of the node's database and its own relay, under the §4.10 operational conditions); §4.6 (encrypted network redundancy; self-hostable relay/blob store; open Class-B token provenance so a token survives its issuer); §4.10 (responsibility tiers; the ≥1-live-holder guarantee, operational not cryptographic) | ## Conventions @@ -171,7 +171,7 @@ Normative keywords (**MUST**, **MUST NOT**, **SHOULD**, **MAY**) follow RFC 2119 This document defines **every** zkCoins protocol version and **every** token standard in one file; there is no separate per-version specification. Three independent version axes appear throughout, each named so it can never be read as another: -- **Protocol version (`v1`, `v2`, …).** One pinned circuit family: a fixed `circuit_digest(C)` and `circuit_digest(C_balance)` ([§1.7.9](#179-proof-system-parameters-normative)) and the lineages built on them. A protocol version governs the domain-separation namespace `"zkCoins/v/…"`, the `/v/` REST prefix ([§7.5](#75-node-rest-api-normative)), the `kernel.v` RPC package ([§7.8](#78-kernel-rpc--the-internal-interface-normative)), and the [§7](#7--wire-formats--node-interfaces) wire formats. There is exactly one pinned circuit family per protocol version, and a change to any frozen element defines a **new** protocol version with new digests and new lineages ([§1.7.8](#178-reference-instantiation-status-final-for-v1)); a frozen version's artefacts are never edited in place. This document currently specifies **protocol version v1**. Items marked as a later "v2" feature in the text (for example the Passkey-derived seed of [§1.2](#12-key-hierarchy)) are normative for the **next** protocol version and are recorded here for continuity; a **v1** implementation **MUST NOT** activate them. Domain-separation tags record the version under which a construction is **pinned**, not the version that first activates it: a construction pinned under an earlier version keeps its tag unchanged when a later version activates it, so its derivation stays byte-stable across the version boundary — the Passkey seed derivation of [§1.2](#12-key-hierarchy), a v2 feature, therefore retains its `"zkCoins/v1/…"` tags. +- **Protocol version (`v1`, `v2`, …).** One pinned circuit family: a fixed `circuit_digest(C)` and `circuit_digest(C_balance)` ([§1.7.9](#179-proof-system-parameters-normative)) and the lineages built on them. A protocol version governs the domain-separation namespace `"zkCoins/v/…"`, the `/v/` REST prefix ([§7.5](#75-node-rest-api-normative)), the `kernel.v` RPC package ([§7.8](#78-kernel-rpc--the-internal-interface-normative)), and the [§7](#7--wire-formats--node-interfaces) wire formats. There is exactly one pinned circuit family per protocol version, and a change to any frozen element defines a **new** protocol version with new digests and new lineages ([§1.7.8](#178-reference-instantiation-status-final-for-v1)); a frozen version's artefacts are never edited in place. This document currently specifies **protocol version v1**. Items marked as a later "v2" feature in the text (for example the Passkey-derived seed of [§1.2](#12-key-hierarchy) and the Marmot/MLS group overlay of [Group chat](/group-chat)) are normative for the **next** protocol version and are recorded here for continuity; a **v1** implementation **MUST NOT** activate them. Domain-separation tags record the version under which a construction is **pinned**, not the version that first activates it: a construction pinned under an earlier version keeps its tag unchanged when a later version activates it, so its derivation stays byte-stable across the version boundary — the Passkey seed derivation of [§1.2](#12-key-hierarchy), a v2 feature, therefore retains its `"zkCoins/v1/…"` tags. - **Token standard (`issuance_version` 1, 2, 3, …).** An issuance schema in the token-standards catalog of [§6.5](#65-issuance--token-standards) — the rules governing an asset's supply and minting. This is an **independent** counter and is **not** a protocol version: the trailing `V` / `_v` in a token standard's derivation tags (`AssetIdV`, `IssuanceTerms_v`) denotes the **token-standard number**, never the protocol version. The standard **number** is an independent counter, but the circuit machinery a standard needs is not: a token standard is realised as an in-circuit version branch of the circuit `C` ([§6.5](#65-issuance--token-standards) *Adding new token standards*). A standard already built into a protocol version's **pinned** circuit (standards 1 and 2 in v1) needs no new protocol version; a standard that requires a branch **not** present in an already-pinned circuit changes `C` and therefore ships only as part of a **new** protocol version ([§1.7.8](#178-reference-instantiation-status-final-for-v1)). A token standard keeps its own number across whichever protocol version first provides its machinery. @@ -284,7 +284,7 @@ A deterministic seed test vector (SHA-256 / HKDF only — no Poseidon) is in [V. | `nk` | wallet, and the wallet's **own** node (operational bundle) | compute nullifiers — required in the proving witness ([§2.1 clause 4](#21-the-compliance-predicate)) | spend; it **can link the account's own spends**, which is why it is entrusted only to the account's own node, never a foreign one | | `ivk` | wallet, and any node the wallet delegates to | detect & decrypt **incoming** coins | spend | | `ovk` | same | recover **outgoing** coin plaintext via the per-coin `out_ciphertext` (§1.3) | spend | -| `op` | the node | act as the standard Nostr identity, send/read NIP-17 messages, sign profiles, relay lists, view grants & acknowledgements | spend, decrypt others' coins | +| `op` | the node | act as the standard Nostr identity, send/read NIP-17 messages, sign profiles, relay lists, view grants & acknowledgements; **v2 only:** when `group_chat` is on, sign Marmot account-identity proofs and kind-30443 KeyPackage events | spend, decrypt others' coins, derive MLS secrets | | `K_tx` (per-coin note key, §1.3) | derived per coin; shareable | decrypt **exactly one** coin | spend, see any other coin | The **operational bundle** `{ivk, ovk, op, nk, op_secret}` is what a wallet entrusts to its **own** node so the node can receive, prove, and serve on its behalf 24/7 ([§6.2](#62-wallet--node)). None of it can spend; `nk` additionally lets its holder link the account's own spends, which is why the bundle goes only to the account's own node. A *foreign* node never receives the bundle; the wallet instead issues that node a scoped, `op`-signed **view grant** ([§5.2](#52-view-grant)). @@ -573,11 +573,11 @@ Inclusion proofs and the consistency (`prefix`) relation over this log are defin #### 1.7.8 Reference-instantiation status (final for v1) -This section pins one concrete, implementable convention for everything otherwise underspecified at the cryptographic-engineering level. It is normative for protocol version v1 — a conforming implementation MUST match it bit-for-bit. **By explicit project decision the instantiation is final for v1:** there is no pre-mainnet external review or audit gate, and v1 accepts the conjectured security margins as stated ([§1.7.9](#179-proof-system-parameters-normative)). Any refinement of the Poseidon parameter choice, the byte→field encoding, the sponge variant, the `serialize(AccountState)` field ordering, or the in-circuit/out-of-circuit boundary is a **version bump** (the tag prefix `"zkCoins/v1/…"` reserves the namespace) — never a change to v1. +This section pins one concrete, implementable convention for everything otherwise underspecified at the cryptographic-engineering level. It is normative for protocol version v1 — a conforming implementation MUST match it bit-for-bit. **By explicit project decision the instantiation is final for v1:** there is no pre-mainnet human-gated review step, and v1 accepts the conjectured security margins as stated ([§1.7.9](#179-proof-system-parameters-normative)). Any refinement of the Poseidon parameter choice, the byte→field encoding, the sponge variant, the `serialize(AccountState)` field ordering, or the in-circuit/out-of-circuit boundary is a **version bump** (the tag prefix `"zkCoins/v1/…"` reserves the namespace) — never a change to v1. **v1 freeze (normative).** The v1 protocol surface is **frozen** in two classes with distinct effectiveness points on the [Path to mainnet runbook](/implementation-mandate). **From runbook step 3 (vectors pin):** the circuit shape of `C` and `C_balance` (public-input layout, the [§2.5](#25-circuit-dimensioning-normative) bounds, the [§2.6](#26-in-circuit-non-native-cryptography-normative) relations), the §1.7 encodings and serializations, and every Poseidon-dependent pinned value — the class that **carries digests and lineages**. `IssuanceTerms_v2` ([§6.5](#65-issuance--token-standards), [§2.1 clause 3](#21-the-compliance-predicate)) is part of the **initial** v1 circuit build, not a later addition. Once the reference implementation generates and pins `circuit_digest(C)` and `circuit_digest(C_balance)` ([§1.7.9](#179-proof-system-parameters-normative), [V.4](#v4-poseidon-derived-values)), any change to any element of this class defines a **new protocol version** with new digests and new lineages; v1 artefacts are never edited in place. **From runbook step 7 (public testnet):** additionally the [§7](#7--wire-formats--node-interfaces) wire formats. From that point nodes that are not jointly updated run for the first time; a wire change thereafter produces identical version names under different rules and is therefore a real version conflict — any change to a §7 wire format after step 7 defines a **new protocol version**. **Between step 3 and step 7** an addition to the §7 wire formats that touches **neither** a circuit element **nor** a pinned vector **nor** a digest is **not** a new protocol version; it **MUST** be introduced by a specification PR that states why the addition is required. The open, additive `GET /v1/token//provenance` read ([§7.5](#75-node-rest-api-normative), [§4.6](#46-data-availability) Class B) is exactly such an addition — read-only, unauthenticated, and changing no existing format. Between those steps no foreign node and no lineage carries value (the project is green field — [Implementation Mandate §0](/implementation-mandate); rollback before runbook step 10 is free — [Path to mainnet](/implementation-mandate)), so a wire freeze protects nothing while forcing a full rebuild of the vector series (the tag prefixes `"zkCoins/v1/…"` enter every Poseidon digest). The digest- and lineage-carrying class is **not** relaxed by this separation: it remains absolute from step 3. -**Residual review target (normative note).** v1 ships without an external audit ([Assurance Roadmap](/assurance)). Of the v1 construction, the one element for which independent cryptographic review is explicitly recommended (but is **not** a v1 release gate — v1 discharges it via the mandatory differential-test below, not a human review; no external-audit step, project decision 2026-07-22) is the **in-circuit arithmetization of the RFC-6962 log-consistency verifier** ([§3.7](#37-the-nullifier-accumulator)): its data-dependent recursion (split points driven by the bits of the two log sizes) is unrolled to `≤ 2·H_MAX` slots with select gates, and a subtly wrong split-point or peak-bagging would let it accept a **non-prefix**, collapsing the transitive-anchoring soundness ([§2.1 clause 1](#21-the-compliance-predicate)). The reference implementation **MUST** differential-test the gadget against an independent RFC-6962 reference at **every** `2ᵏ−1`, `2ᵏ`, `2ᵏ+1` size boundary for **`k = 0…63`** — the **generated log-boundary suite** of [V.11](#v11-nullifier-accumulator-log-vectors). That suite tests the **split-/peak-bagging LOGIC** of the consistency and inclusion gadgets with **given/symbolic subtree-root fixtures** (the O(log n) boundary subtree roots per case — Poseidon-dependent, hence ``), **not** by materialising Θ(n) leaves for large `n`. Small hand-listed sizes (`n ≤ 9`) **MAY** fully materialise; high-`k` cases **MUST NOT** require Θ(n) leaf evaluation. The suite is part of the **v1 freeze differential-test** (this section) and **feeds the D-05 release gate** (in-circuit differential-test of the RFC-6962 log-consistency + inclusion arithmetization — [Paper-Deviation Analysis D-05](/paper-conformance-analysis)). The inclusion **PATH** gadget ([§3.7](#37-the-nullifier-accumulator)) shares the same data-dependent split-point arithmetization (driven by the position bits, `≤ H_MAX` audit-path hashes) and is covered by the **same** differential-test discipline and the V.11 vectors (hand-listed smoke set **and** the symbolic-subtree-root suite for all `k`). The abstract relation is peer-reviewed (RFC 6962 / RFC 9162 log consistency); only its Poseidon-over-Goldilocks in-circuit realisation is v1-new. +**Residual review target (normative note).** Of the v1 construction, the one element the mandatory differential-test below discharges (project decision 2026-07-22) is the **in-circuit arithmetization of the RFC-6962 log-consistency verifier** ([§3.7](#37-the-nullifier-accumulator)): its data-dependent recursion (split points driven by the bits of the two log sizes) is unrolled to `≤ 2·H_MAX` slots with select gates, and a subtly wrong split-point or peak-bagging would let it accept a **non-prefix**, collapsing the transitive-anchoring soundness ([§2.1 clause 1](#21-the-compliance-predicate)). The reference implementation **MUST** differential-test the gadget against an independent RFC-6962 reference at **every** `2ᵏ−1`, `2ᵏ`, `2ᵏ+1` size boundary for **`k = 0…63`** — the **generated log-boundary suite** of [V.11](#v11-nullifier-accumulator-log-vectors). That suite tests the **split-/peak-bagging LOGIC** of the consistency and inclusion gadgets with **given/symbolic subtree-root fixtures** (the O(log n) boundary subtree roots per case — Poseidon-dependent, hence ``), **not** by materialising Θ(n) leaves for large `n`. Small hand-listed sizes (`n ≤ 9`) **MAY** fully materialise; high-`k` cases **MUST NOT** require Θ(n) leaf evaluation. The suite is part of the **v1 freeze differential-test** (this section) and **feeds the D-05 release gate** (in-circuit differential-test of the RFC-6962 log-consistency + inclusion arithmetization — [Paper-Deviation Analysis D-05](/paper-conformance-analysis)). The inclusion **PATH** gadget ([§3.7](#37-the-nullifier-accumulator)) shares the same data-dependent split-point arithmetization (driven by the position bits, `≤ H_MAX` audit-path hashes) and is covered by the **same** differential-test discipline and the V.11 vectors (hand-listed smoke set **and** the symbolic-subtree-root suite for all `k`). The abstract relation is peer-reviewed (RFC 6962 / RFC 9162 log consistency); only its Poseidon-over-Goldilocks in-circuit realisation is v1-new. #### 1.7.9 Proof-system parameters (normative) @@ -1210,7 +1210,7 @@ A **publisher** is the permissionless agent that moves nullifiers from off-chain - Running a publisher MUST be permissionless; any participant MAY run one, and a wallet's own node MAY act as its publisher. - A publisher MUST NOT be trusted for **correctness**: it cannot forge, alter, reorder-to-steal, or drop-without-detection any nullifier, because (a) each signature is verified by every scanning node (§3.6), and (b) the value-bearing proof and coin plaintext travel off-chain ([Transport & Recovery](#4--transport--recovery)), never through the publisher. - A publisher MUST NOT be trusted for **custody**: it never holds a spend key and never holds any customer coin or proof — in v1 it receives no coin or proof at all, only the nullifier hand-off; under the deferred fee mechanism the one object it would receive is its **own fee coin's** `CoinProof` ([§3.8.1](#381-fee-coin-mechanism-deferred), [§7.6](#76-publisher-interface-normative)); the worst a faulty or malicious publisher can do is **censor** (refuse to inscribe) or **delay** — both mitigated because anyone else can publish the same nullifier, and the censored spender can submit to a different publisher. -- **Contention-free self-publish (normative).** A nullifier references **no shared global state** — no accumulator root, no other transition — so **any** node can inscribe its own transitions independently, at any time, with no ordering slot to win and no risk of going stale ([Requirement: every node publishes its own transactions without competitive pressure](/requirements)). There is no single sequential writer: two publishers inscribing in the same block never conflict, because each nullifier is folded into the accumulator by first-occurrence on its own key `Pkⱼ` (§3.6). Redundant publication is idempotent — a scanner folds each unique `Pkⱼ` once, and a second inscription of an already-folded `Pkⱼ` is a no-op (§3.6). +- **Contention-free self-publish (normative).** A nullifier references **no shared global state** — no accumulator root, no other transition — so **any** node can inscribe its own transitions independently, at any time, with no ordering slot to win and no **contention-driven** staleness — a self-published nullifier never goes stale by losing a race for a shared slot ([Requirement: every node publishes its own transactions without competitive pressure](/requirements)); the independent [§3.5](#35-inscription-format) `block_anchor` freshness bound still governs how long a not-yet-inscribed hand-off stays inscribable. There is no single sequential writer: two publishers inscribing in the same block never conflict, because each nullifier is folded into the accumulator by first-occurrence on its own key `Pkⱼ` (§3.6). Redundant publication is idempotent — a scanner folds each unique `Pkⱼ` once, and a second inscription of an already-folded `Pkⱼ` is a no-op (§3.6). - A publisher SHOULD batch over a bounded interval (e.g. once per Bitcoin block) and SHOULD half-aggregate (§3.3) to minimise per-transition cost. Larger aggregates amortise the constant per-inscription header more aggressively (§3.8), but the marginal per-transition footprint is already ~64 bytes and never depends on shared state. A publisher is only marginally heavier than a plain broadcaster: it half-aggregates collected signatures (§3.3, no secret keys, cheap) and broadcasts one inscription. It holds **no** recursive proof, **no** off-chain bundle, and **no** consensus-critical data — every value-bearing artefact travels sender→receiver off-chain, never through the publisher. A publisher's "right to publish" rests entirely on its ability to reach the bitcoind-broadcast surface; it need not prove anything. @@ -1301,7 +1301,7 @@ Any node rebuilds the global **nullifier accumulator** from Bitcoin alone, trust Because steps 1–5 are a pure function of confirmed Bitcoin data **from the pinned `activation_height` onward** (§3.6 Scan origin), two honest nodes scanning the same chain MUST arrive at the **identical** nullifier accumulator — no node-supplied root, and no off-chain data, is ever consulted. A wallet or explorer therefore computes the accumulator itself, or checks any served (non-)membership answer against its own copy, never by trusting the server ([Requirement 4](/requirements), [Requirement 10](/requirements)). This is the property the retired batched design could not offer: with the nullifiers on Bitcoin, admission is objective and availability-independent, so two honest nodes at the same tip can never diverge on the accumulator. -**Scan origin (normative, consensus-critical).** Because the log is **position-bound** (`Hc("NfLog/Leaf", p ‖ Pk ‖ R)`, [§1.7.6](#176-nullifier-accumulator-append-only-merkle-log)), the height at which the scan begins is a **consensus parameter**: two nodes that start at different heights fold different first-occurrence sequences and assign **different positions** to the same nullifier, so every inclusion proof, every `prefix` chain, and every `nav` diverges between them. zkCoins therefore pins one **`activation_height` per network** as part of the frozen network parameter set (alongside the network tag and `circuit_digest`, [§1.7.9](#179-proof-system-parameters-normative)): position `0` is the first surviving nullifier in a block at height `≥ activation_height`, and any inscription below it is **not** part of the accumulator. For **mainnet** the `activation_height` is pinned at deployment (the genesis/deployment runbook step) and is identical across all nodes; for **testnet** and **regtest** it is a fixed constant of the network definition. A node **MUST** reject a configured `activation_height` that does not match the pinned network value. **Values (normative).** `regtest` fixes `activation_height = 0` (scan from the regtest genesis; regtest chains are ephemeral). `testnet` (on **Bitcoin Signet**) fixes `activation_height` to **exactly the Signet block height of the testnet's genesis inscription**, observed when the public testnet is stood up (runbook step 7) and published in its network parameter set — the same observed-genesis rule as mainnet. For **mainnet** the operator first broadcasts the network's genesis inscription (runbook step 9); once it confirms, `activation_height` is set to **exactly the Bitcoin block height of the block that carries it** — a unique value the operator **observes** (it is not chosen ahead of time), then publishes in the network parameter set, thereafter **immutable**. The genesis inscription is therefore the **only** zkCoins inscription at `activation_height` with no earlier one possible, so no node can diverge on position 0. Every node **MUST** load the published per-network value and a node whose configured value differs **MUST** refuse to become ready (`/health/ready` stays `503`). This forecloses a pre-deployment-inscription split: an adversary who writes valid `0x42 0x42` payloads before `activation_height` cannot shift any node's positions, because those blocks are below the pinned origin. The **network parameter set** is the pinned tuple `{ network_tag, circuit_digest(C), circuit_digest(C_balance), activation_height, finality_confirmations = 6, bootstrap_pubkey }`, published as a content-addressed `network-params.json` in the deployment and echoed by `GET /v1/info` (§7.5); it is byte-identical across all nodes of a network. **`bootstrap_pubkey`** is the BIP-340 x-only public key (32 bytes) that alone may sign that network's `BootstrapManifestV1` ([§4.3](#43-addressing-for-delivery)); its concrete value is deployment-pinned per network (testnet/regtest fixtures pin their own keys; mainnet pins at genesis). Its **canonical encoding** (what "content-addressed" is over) is the byte string `network_tag_len (u8) ‖ network_tag (UTF-8) ‖ circuit_digest(C) (32B) ‖ circuit_digest(C_balance) (32B) ‖ activation_height (8B big-endian) ‖ finality_confirmations (1B = 0x06) ‖ bootstrap_pubkey (32B x-only)`; the artefact's identifier is `SHA-256` of that byte string, and `GET /v1/info` echoes the same fields. Two nodes agree iff this byte string is identical. **Relation to the "pure function of on-chain data" claim (normative).** `activation_height` is a **pinned, immutable network constant** — a member of the frozen network parameter set alongside the network tag and `circuit_digest` — **not** mutable off-chain state and **not** a peer's claim. The accumulator therefore remains a pure function of the on-chain nullifiers **relative to this pinned parameter set**: a verifier that already holds the network's constants (as it must to verify any proof) reconstructs the identical log from Bitcoin alone. Wherever this document says the accumulator is "rebuilt from Bitcoin alone", read it as "from Bitcoin alone, **given the pinned network parameters**". **Honest caveat (normative):** unlike the network tag and `circuit_digest`, which are **cryptographically bound** into the verifier data — a proof against the wrong network simply fails to verify (§1.7.9) — `activation_height` is **not** a circuit public input; it is enforced only by the node-config equality check and the readiness gate above. Two nodes with identical verifier data but a **different** accepted `activation_height` would verify the same proofs yet fold **different** log positions and `nav`s **whenever the interval between their two `activation_height` values contains at least one admitted first-occurrence nullifier** (with none, the log `(size, mth)` is identical regardless of the origin). The log's canonicity therefore carries a **distinct, weaker parameter-agreement assumption** — that every node loads the same pinned `network-params.json` — which the paper's per-user model does not have. It is registered as a residual of **D-05** and gated by the `network-params.json` byte-exactness plus this readiness check ([Paper-Conformance Remediation](/paper-conformance-remediation)). +**Scan origin (normative, consensus-critical).** Because the log is **position-bound** (`Hc("NfLog/Leaf", p ‖ Pk ‖ R)`, [§1.7.6](#176-nullifier-accumulator-append-only-merkle-log)), the height at which the scan begins is a **consensus parameter**: two nodes that start at different heights fold different first-occurrence sequences and assign **different positions** to the same nullifier, so every inclusion proof, every `prefix` chain, and every `nav` diverges between them. zkCoins therefore pins one **`activation_height` per network** as part of the frozen network parameter set (alongside the network tag and `circuit_digest`, [§1.7.9](#179-proof-system-parameters-normative)): position `0` is the first surviving nullifier in a block at height `≥ activation_height`, and any inscription below it is **not** part of the accumulator. For **mainnet** the `activation_height` is pinned at deployment (the genesis/deployment runbook step) and is identical across all nodes; for **testnet** and **regtest** it is a fixed constant of the network definition. A node **MUST** reject a configured `activation_height` that does not match the pinned network value. **Values (normative).** `regtest` fixes `activation_height = 0` (scan from the regtest genesis; regtest chains are ephemeral). `testnet` (on **Bitcoin Signet**) fixes `activation_height` to **exactly the Signet block height of the testnet's genesis inscription**, observed when the public testnet is stood up (runbook step 7) and published in its network parameter set — the same observed-genesis rule as mainnet. For **mainnet** the operator first broadcasts the network's genesis inscription (runbook step 9); once it confirms, `activation_height` is set to **exactly the Bitcoin block height of the block that carries it** — a unique value the operator **observes** (it is not chosen ahead of time), then publishes in the network parameter set, thereafter **immutable**. The genesis inscription is therefore the **only** zkCoins inscription at `activation_height` with no earlier one possible, so no node can diverge on position 0. Every node **MUST** load the published per-network value and a node whose configured value differs **MUST** refuse to become ready (`GET /v1/{network}/health/ready` stays `503`; single-network **MAY**: `GET /health/ready`). This forecloses a pre-deployment-inscription split: an adversary who writes valid `0x42 0x42` payloads before `activation_height` cannot shift any node's positions, because those blocks are below the pinned origin. The **network parameter set** is the pinned tuple `{ network_tag, circuit_digest(C), circuit_digest(C_balance), activation_height, finality_confirmations = 6, bootstrap_pubkey }`, published as a content-addressed `network-params.json` in the deployment and echoed by `GET /v1/info` (§7.5); it is byte-identical across all nodes of a network. **`bootstrap_pubkey`** is the BIP-340 x-only public key (32 bytes) that alone may sign that network's `BootstrapManifestV1` ([§4.3](#43-addressing-for-delivery)); its concrete value is deployment-pinned per network (testnet/regtest fixtures pin their own keys; mainnet pins at genesis). Its **canonical encoding** (what "content-addressed" is over) is the byte string `network_tag_len (u8) ‖ network_tag (UTF-8) ‖ circuit_digest(C) (32B) ‖ circuit_digest(C_balance) (32B) ‖ activation_height (8B big-endian) ‖ finality_confirmations (1B = 0x06) ‖ bootstrap_pubkey (32B x-only)`; the artefact's identifier is `SHA-256` of that byte string, and `GET /v1/info` echoes the same fields. Two nodes agree iff this byte string is identical. **Relation to the "pure function of on-chain data" claim (normative).** `activation_height` is a **pinned, immutable network constant** — a member of the frozen network parameter set alongside the network tag and `circuit_digest` — **not** mutable off-chain state and **not** a peer's claim. The accumulator therefore remains a pure function of the on-chain nullifiers **relative to this pinned parameter set**: a verifier that already holds the network's constants (as it must to verify any proof) reconstructs the identical log from Bitcoin alone. Wherever this document says the accumulator is "rebuilt from Bitcoin alone", read it as "from Bitcoin alone, **given the pinned network parameters**". **Honest caveat (normative):** unlike the network tag and `circuit_digest`, which are **cryptographically bound** into the verifier data — a proof against the wrong network simply fails to verify (§1.7.9) — `activation_height` is **not** a circuit public input; it is enforced only by the node-config equality check and the readiness gate above. Two nodes with identical verifier data but a **different** accepted `activation_height` would verify the same proofs yet fold **different** log positions and `nav`s **whenever the interval between their two `activation_height` values contains at least one admitted first-occurrence nullifier** (with none, the log `(size, mth)` is identical regardless of the origin). The log's canonicity therefore carries a **distinct, weaker parameter-agreement assumption** — that every node loads the same pinned `network-params.json` — which the paper's per-user model does not have. It is registered as a residual of **D-05** and gated by the `network-params.json` byte-exactness plus this readiness check ([Paper-Conformance Remediation](/paper-conformance-remediation)). The operative double-spend check is **per-transition** (§3.7): a verifier confirms a coin's **creating** transition is anchored by checking that transition's `(Pkᵢ, Rᵢ)` is the first occurrence of `Pkᵢ` in the accumulator it rebuilt from the chain, with `Rᵢ` opening to the creating transition's `H(ProofData)` (§3.2, [§2.3.3 step 4](#233-receive)). There is no global root to fetch and no per-coin membership path needs to travel inside a `CoinProof` bundle, because the verifier holds the whole published nullifier set itself. @@ -1323,7 +1323,7 @@ Double-spend protection is enforced **on-chain and trustlessly** by the global * **`SUBPROOF` / consistency witness — not a v1 wire object (normative).** The RFC-6962 consistency / inclusion `SUBPROOF` node-list (and the circuit witnesses that carry it — `nav_consistency`, `creating_nav_consistency`, [§2.1](#21-the-compliance-predicate)) is an **implementation-internal circuit witness**: it is constructed and consumed inside the prover / verifier of `C` and **is not** a v1 public REST, Blossom, Nostr, or kernel-RPC wire object. v1 clients (thin-wallet trusts node, [§6.2](#62-wallet--node)) **do not** verify log-consistency themselves against a remote peer; Path-B surfaces only inclusion (and the unauthenticated absence answer) on `GET /v1/chain/nullifier/` ([§7.5](#75-node-rest-api-normative)). Transporting a consistency `SUBPROOF` across component boundaries as a first-class object would be a separate **v2** wire fix, not part of this specification. -**Why this closes fork-burial (soundness).** A consistency proof forces `mth_a` to commit **exactly** the first `m` leaves of `mth_b`, so any leaf authenticated at a position `p < m` under `a` is the **identical** leaf at `p` under `b`; this composes **transitively** along the recursion's prefix chain up to the top `w.nav`, which the receiver checks **canonical** against its own `NAV(tip)` scan ([§2.3.3 step 2](#233-receive)). Because the first-occurrence fold appends each `Pkᵢ` **at most once**, carrying the **winner** `Rᵢ` ([§3.6](#36-chain-scanning)), a fork loser `(Pkᵢ, R_loser ≠ R_winner)` sits at **no** canonical position; a proof that buries it at a deep internal hop and lifts it to a canonical top is **unsatisfiable** without a Poseidon collision (canonical position `p` would have to equal both the genuine winner leaf and the forged loser leaf). Leaf-preservation is therefore a **theorem** here, not a per-leaf relation to enforce. The consistency recursion's exact behaviour is pinned above and reference-tested at the `2ᵏ−1, 2ᵏ, 2ᵏ+1` size boundaries for **every** `k = 0…63` by the **generated log-boundary suite** of [V.11](#v11-nullifier-accumulator-log-vectors) — exercising split/peak-bagging against **symbolic O(log n) subtree-root fixtures**, not Θ(n) leaf materialisation (the freeze differential-test of [§1.7.8](#178-reference-instantiation-status-final-for-v1); feeds the D-05 release gate); its in-circuit arithmetization is the one element flagged for cryptographic review at [§1.7.8](#178-reference-instantiation-status-final-for-v1). +**Why this closes fork-burial (soundness).** A consistency proof forces `mth_a` to commit **exactly** the first `m` leaves of `mth_b`, so any leaf authenticated at a position `p < m` under `a` is the **identical** leaf at `p` under `b`; this composes **transitively** along the recursion's prefix chain up to the top `w.nav`, which the receiver checks **canonical** against its own `NAV(tip)` scan ([§2.3.3 step 2](#233-receive)). Because the first-occurrence fold appends each `Pkᵢ` **at most once**, carrying the **winner** `Rᵢ` ([§3.6](#36-chain-scanning)), a fork loser `(Pkᵢ, R_loser ≠ R_winner)` sits at **no** canonical position; a proof that buries it at a deep internal hop and lifts it to a canonical top is **unsatisfiable** without a Poseidon collision (canonical position `p` would have to equal both the genuine winner leaf and the forged loser leaf). Leaf-preservation is therefore a **theorem** here, not a per-leaf relation to enforce. The consistency recursion's exact behaviour is pinned above and reference-tested at the `2ᵏ−1, 2ᵏ, 2ᵏ+1` size boundaries for **every** `k = 0…63` by the **generated log-boundary suite** of [V.11](#v11-nullifier-accumulator-log-vectors) — exercising split/peak-bagging against **symbolic O(log n) subtree-root fixtures**, not Θ(n) leaf materialisation (the freeze differential-test of [§1.7.8](#178-reference-instantiation-status-final-for-v1); feeds the D-05 release gate); its in-circuit arithmetization is the one element covered by the mandatory [§1.7.8](#178-reference-instantiation-status-final-for-v1)/[V.11](#v11-nullifier-accumulator-log-vectors) differential-test. **Double-spend check (per-transition, `Pkᵢ`-keyed).** To confirm a coin's creating transition is a valid, non-double-spent state update as of `tip`, a verifier checks that transition's `Pkᵢ` against the accumulator it **rebuilt itself** from the chain at `NAV(tip)` (§3.6) — never against a root supplied by a node: @@ -1379,7 +1379,7 @@ A transition nullifier is **published** the instant its reveal transaction enter 1. **Reorgs of up to 5 blocks are tolerated.** They only ever touch **non-final** nullifiers (fewer than 6 confirmations). Every node handles them by **canonical replay** — **exclude orphaned entries from the canonical view; retained, never deleted**: every nullifier `Pkᵢ` published only in orphaned blocks stays permanently in the store (archived) and is only excluded from the active canonical head/index (or materialised view); then re-fold first-occurrence over the new canonical order (§3.6). The accumulator at the new tip is the deterministic replay of publications in the new canonical order — no re-batching, no publisher coordination, no stranded shared root, and no physical deletion of already-stored log entries (Data Permanence, [§4.8](#48-durability--the-store-everything-invariant)). 2. **Nothing final is reversed by a tolerated reorg.** A receiver **MUST NOT** credit a coin until its creating nullifier is final (§3.10 `completed`). Because a ≤5-block reorg touches only **non-final** nullifiers, it cannot reverse a credited (final) coin or a final account state; a wallet simply re-publishes any orphaned not-yet-final transition after canonical replay. No final value is lost within the tolerated window. (Because `nav` is always `size_final` (§2.3.2 step 5), a transition is never built against a not-yet-final dependency — the wallet waits until the dependency finalizes; this is what forecloses the account-brick a non-final `nav` would otherwise cause under a tolerated reorg.) -3. **A reorg of 6 or more blocks MAY break zkCoins.** Displacing a final nullifier is **outside v1's guarantee**: such a reorg can orphan a dependency a completed transition relied on, or reverse a credited coin, and v1 provides **no** recovery path for it. This is an **explicit, accepted limitation** of v1 — the stated boundary of the protocol's safety, so that integrators size their confirmation policy accordingly. Deployments handling extreme value **MAY** adopt additional out-of-band confirmation policies on top of the 6-confirmation floor. A node that detects such a displacement of a final nullifier **MUST** surface the condition — its `/health/ready` **MUST** stop reporting ready — and **MUST NOT** continue crediting against the broken state. +3. **A reorg of 6 or more blocks MAY break zkCoins.** Displacing a final nullifier is **outside v1's guarantee**: such a reorg can orphan a dependency a completed transition relied on, or reverse a credited coin, and v1 provides **no** recovery path for it. This is an **explicit, accepted limitation** of v1 — the stated boundary of the protocol's safety, so that integrators size their confirmation policy accordingly. Deployments handling extreme value **MAY** adopt additional out-of-band confirmation policies on top of the 6-confirmation floor. A node that detects such a displacement of a final nullifier **MUST** surface the condition — its `GET /v1/{network}/health/ready` **MUST** stop reporting ready (single-network **MAY**: `GET /health/ready`) — and **MUST NOT** continue crediting against the broken state. This is a deliberate deviation from *Shielded CSV*, which uses a tuple-of-sets accumulator with an `IsPrefix`/`DistinctElement` **exactly-one-of** relation to make reorgs of arbitrary depth defined and survivable (a conditional-NAV no-op branch). zkCoins v1 uses an **append-only Merkle-log accumulator with RFC-6962 log-consistency** with no `DistinctElement` no-op branch ([§3.7](#37-the-nullifier-accumulator)), and therefore does **not** inherit the paper's arbitrary-depth recovery; it replaces it with this hard 6-confirmation finality bound. The deviation and its rationale are registered in the [paper-deviation analysis](/paper-conformance-analysis); the Bitcoin-industry 6-confirmation default makes it the established, Bitcoin-consistent choice. @@ -1422,7 +1422,7 @@ Every zkCoins **node is paired with a full Nostr relay** for transport. In the r The transport key is `op`, the operational / Nostr identity key ([Foundations §1.2](#12-key-hierarchy)). It is the account's **ordinary Nostr account key**, not a zkCoins-specific messaging key: its public key authors the standard kind-0 profile and kind-10050 DM relay list, and `op` signs NIP-59 seals for NIP-17 messages. The node holding the operational bundle drives transport and messaging on the wallet's behalf; `op` **MUST NOT** be able to spend (it is a hardened sibling of the SPEND branch). A sovereign user's own node therefore controls and can read that user's messages; a hosted provider holding the bundle can do the same for its hosted account ([§6.6](#66-threat-model-and-trust-configurations)). Interoperability means ordinary NIP-17 peers exchange standard events directly with this Nostr identity — no external peer needs the `op` secret or any zkCoins-specific adapter. -Human messages and zkCoins coin delivery are separate protocols on the same Nostr identity. Human messages **MUST** use NIP-17 kinds `14`, `13`, `1059`, and `10050`; zkCoins delivery and acknowledgement rumors remain kinds `1420` and `1421` and **MUST NOT** carry chat content ([§7.3](#73-nostr-event-kinds-normative)). +Human messages and zkCoins coin delivery are separate protocols on the same Nostr identity. One-to-one human messages **MUST** use NIP-17 kinds `14`, `13`, `1059`, and `10050`. Marmot/MLS group kinds `444`, `445`, and `30443` are a **v2 feature — NOT applicable in v1** ([Group chat](/group-chat)); they are not NIP-17 rooms. A **v1** implementation **MUST NOT** advertise or process them as group chat. zkCoins delivery and acknowledgement rumors remain kinds `1420` and `1421` and **MUST NOT** carry chat content or group plaintext ([§7.3](#73-nostr-event-kinds-normative)). The transport is trusted only for **availability** and for **metadata minimisation** — never for correctness. A relay can **withhold** a bundle but can neither **forge** nor **alter** one, because the recipient verifies every bundle cryptographically (§4.5). This is the same trust spectrum as the node model: a compromised relay is a privacy/availability problem, never theft. @@ -1735,12 +1735,12 @@ A directly supplied valid `nprofile` can be used and retained without DNS. Until **Trust and portability.** NIP-05 first discovery is a name-to-key binding; what is pinned on first successful use is the **key**, not the name. The `addr_sig` separately binds the discovered Nostr key and zkCoins delivery fields to the payment address through the three checks above. A name appears in no value-bearing structure and in no payment-authorising preimage. It enters `name_message`, where the seed holder attests it, and the kind-0 event serialization signed by `op`; neither grants payment authority. Funds remain on `address`, and loss of a domain can impair first discovery through that name but cannot spend or destroy funds, invalidate a key, or unpin a contact. Naming the account under a second domain is not a remedy either, since one name is in force at a time (*One active name* above): first discovery through a lost domain is lost until the holder publishes a new name and communicates it out of band. Domain loss does not break a known contact while at least one retained, original-hostname-authenticated TLS endpoint still completes the relay WebSocket upgrade on its original path; it does not guarantee availability beyond that explicit boundary. -**One name, independent optional bridges.** A name and NIP-17 messaging are both mandatory; what stays optional is each bridge. A name may independently support either, both, or neither: +**One name, independent optional bridges.** A name and NIP-17 messaging are both mandatory; what stays optional is each name-bound bridge. A name may independently support either, both, or neither of: - Lightning/LNURL: kind-0 includes `"lud16": "alice@example.com"` and the domain serves `https://example.com/.well-known/lnurlp/alice`; - SMTP/email: the domain accepts ordinary email for `alice@example.com`. -Disabling either bridge **MUST NOT** disable NIP-05 identity, NIP-17 messaging, or native zkCoins delivery. A receive QR may encode the shared identifier; it never needs to expose a raw `zk1…` address or bare `lnurl1…` value. +**Independent group overlay (v2 feature — NOT applicable in v1).** Marmot/MLS group chat is not a name capability and is **not** part of protocol v1. A **v1** implementation **MUST NOT** advertise or serve it. When protocol v2 activates it, it is the API feature `group_chat` on the account's `op_pubkey` ([Group chat](/group-chat)). Enabling or disabling `group_chat` **MUST NOT** change NIP-05 identity, NIP-17 messaging, native zkCoins delivery, or either bridge. Disabling either bridge **MUST NOT** disable `group_chat`. A receive QR may encode the shared identifier; it never needs to expose a raw `zk1…` address or bare `lnurl1…` value. ### 4.4 Note discovery @@ -1800,6 +1800,7 @@ Custody safety **MUST NOT** depend on availability. Losing availability impairs - **What a relay learns.** That a zkCoins delivery event was stored at some time — the outer event carries the two per-coin cleartext scan tags `zkdt`/`zkepk` (§4.2 step 4), which are fresh and random-looking per coin, so the relay learns *that* an event is a zkCoins delivery (and its timing/volume) but **not** the sender, recipient, amount, asset, proof, or any link between two events (§4.1–§4.2). The tags identify no party and correlate no coins; the residual exposure is that the protocol itself is recognisable on the wire, not the parties or contents. - **Human-message metadata differs.** Standard NIP-17 routing uses the recipient's `op_pubkey` in the outer kind-1059 `p` tag. The public gift-wrap event does not reveal the sender identity, and message content remains encrypted. A DM relay or its operator can nevertheless identify or correlate the sender through NIP-42 AUTH, source IP and connection metadata, or its own authentication and admission rules; it also sees the recipient key, timing, and volume. The payment path's per-coin `zkdt`/`zkepk` privacy claim therefore **MUST NOT** be applied to human messages. +- **Group-chat metadata (v2 only, when `group_chat` is on).** Kind `445` exposes `nostr_group_id` (32 random bytes, not member-derived), a fresh ephemeral event pubkey, timing, and volume; an optional NIP-40 `expiration` further reveals that the event is an application message. Welcomes are NIP-59 gift wraps addressed to the invitee's `op_pubkey` and look like DMs to the invitee's inbox relays. KeyPackages (kind `30443`) are public and authored by `op_pubkey`. The MLS group id **MUST NOT** appear on the public REST surface ([Group chat](/group-chat)). - **NIP-05 discovery uses DNS and HTTPS once.** First discovery exposes the queried identifier to its domain and depends on DNS. The client then connects to relevant relay URLs and retains only endpoints that both authenticate TLS for each original hostname and complete the WebSocket relay upgrade on the original path. Once the `op_pubkey`, standard `nprofile`, relay lists, and those endpoints are retained, normal use and cold start for that known contact make no DNS/NIP-05 request (§4.3). This holds only while at least one retained endpoint still serves that relay path. - **A published `zkcoins` profile is public and permanent.** The kind-0 event carrying `nip05` and the `zkcoins` object ([§7.3](#73-nostr-event-kinds-normative)) is not gift-wrapped: it is public and unencrypted, and — like any Nostr kind-0 — is indexed and archived by ordinary Nostr clients and aggregators, so it cannot be retracted once seen. Publishing it therefore binds that name and public key to `address`, `pk0`, `nk_commit`, and `ivpk` in a searchable public record. `pk0` reaches further than the other three: it is also the account's **genesis on-chain nullifier key**, the `Pk₀` the dense account scan looks for on Bitcoin ([§1.2](#12-key-hierarchy)). So the profile links the name not only to a payment identity but to that account's first transition and its timing on the chain, permanently and for anyone who cares to correlate the two. Later transitions are unaffected — they use rotated keys that never appear in a profile — and the linkage survives any later profile replacement. This is what being payable by a public name consists of ([§4.3](#43-addressing-for-delivery)). A directly issued `Invoice` carries the same fields to one payer instead of to everyone. - **Detection scan vs. linkability.** Per-coin `detect_tag`s are all-distinct (fresh `epk` per coin, §4.4), so a relay cannot link or filter for the recipient. The genuine residual cost is **bandwidth**: detection runs recipient-side over the candidate set. The future-version (not in v1) fuzzy-message-detection upgrade reduces that bandwidth. @@ -1812,7 +1813,7 @@ Custody safety **MUST NOT** depend on availability. Losing availability impairs zkCoins is client-side-validated: a coin's spendability and an account's next-transition credential live **entirely** in off-chain artefacts — the `CoinProof` bundle, the `SelfDeliveryRecordV1`, and the recursive proof each carries. Bitcoin holds only the opaque per-transition nullifier ([§3.1](#31-the-on-chain-object)), which **cannot** reconstruct a lost proof. **Losing the off-chain data is losing the funds, permanently** (a `CoinProof` bundle *is* coin custody; a `SelfDeliveryRecordV1` *is* account-head custody). Durability is therefore a hard safety requirement of every node, not best-effort caching. - **Store everything (MUST).** A node **MUST** durably persist **every** value-bearing artefact the moment it receives it — every `CoinProof` bundle, every delivery event, and every `SelfDeliveryRecordV1` (§4.2) — to its durable store (the kernel's value-bearing PostgreSQL plus blob store; [§6.1](#61-components-and-responsibilities)). It **MUST NOT** treat any such artefact as ephemeral, in-memory-only, or droppable under load. The standing rule is *store everything you can get*: when in doubt, persist. At the moment a node accepts an incoming coin it **MUST** capture not only the coin's own bundle but **everything that could later be needed to keep that coin's token alive and transferable independently of its issuer** — the token's `asset_terms` when the bundle carries them ([§4.6](#46-data-availability) *Token survival independent of the issuer*) — because the issuing node may be unreachable when that information is next needed. -- **Never delete (MUST) — [Data Permanence, Requirement 12](/requirements#12-data-permanence).** Once persisted, a node **MUST NOT** delete, drop, expire, prune, garbage-collect, evict under a retention policy, or otherwise destroy any received artefact — **ever**, and regardless of acknowledgements, redundancy held elsewhere, supersession, age, or storage pressure. Retention is **indefinite and unconditional**. There is **no** drop condition, **no** retention-policy expiry, and **no** "superseded" clean-up: a newer `SelfDeliveryRecordV1` is stored **in addition to**, never in place of, the one it supersedes. Copies held by other nodes only add durability; holding them is **never** a licence to remove a local copy. This applies identically to the kernel store, the API, and any relay or blob store the node runs. +- **Never delete (MUST) — [Data Permanence, Requirement 12](/requirements#12-data-permanence).** Once persisted, a node **MUST NOT** delete, drop, expire, prune, garbage-collect, evict under a retention policy, or otherwise destroy any received artefact — **ever**, and regardless of acknowledgements, redundancy held elsewhere, supersession, age, or storage pressure. Retention is **indefinite and unconditional**. There is **no** drop condition, **no** retention-policy expiry, and **no** "superseded" clean-up: a newer `SelfDeliveryRecordV1` is stored **in addition to**, never in place of, the one it supersedes. Copies held by other nodes only add durability; holding them is **never** a licence to remove a local copy. This applies identically to the kernel store, the API, and any relay or blob store the node runs. **Exception (v2 only):** when protocol v2 has activated `group_chat`, Marmot MLS **application** messages **MAY** follow Marmot `message-retention` / NIP-40; Marmot commits and proposals **MUST NOT** expire, and no value-bearing artefact is in this exception. A **v1** implementation has **no** such carve-out ([Group chat](/group-chat), [Requirement 12](/requirements#12-data-permanence)). - **Persist before acting (MUST).** The durable write **MUST** precede every externally-visible effect — returning the §4.2 ACK, crediting a coin, or serving the artefact to a peer. A node **MUST** order its work so that a crash at any point can never leave it having acted on data it did not first persist. - **Operator durability duty (MUST).** Because a lost artefact can lose funds, the node **operator MUST maintain a real-time, restorable backup** of the node's value-bearing PostgreSQL store (and its blob store) at all times, such that a host failure loses no committed artefact. The backup **mechanism** — for PostgreSQL, standard streaming replication / write-ahead-log archiving / point-in-time recovery, and the equivalent for the blob store — is a **deployment and hosting responsibility** and is deliberately **out of scope for the node software and the node repository**: the node ships no backup subsystem, and the operator provisions one from standard tooling. This duty is the *primary* recovery path ([§4.5](#45-recovery)); the network redundancy layers ([§4.6](#46-data-availability)) are the fallback, never a substitute for it. - **The ACK is a durability confirmation, never a delete trigger.** A node **MUST NOT** return the §4.2 acknowledgement until the artefact is committed to stable storage (fsync / write-ahead log). A valid ACK tells the **sender** that the recipient durably stored the bundle; it does **not** permit the sender to drop its own copy. Under Data Permanence the sender retains its copy indefinitely, exactly like every other holder. @@ -1983,11 +1984,11 @@ The node **MUST** (1) verify the grant's `op` signature against the subject's pu The challenge–response above authorises a **single** `POST /v1/pull` ([§7.5](#75-node-rest-api-normative)): the `nonce` is consumed on use, so it cannot authorise the follow-up `GET /v1/proof/` fetches a client makes after seeing the record list. To bridge those without re-running the challenge per coin, a successful `POST /v1/pull` **also** issues a short-lived **pull session**: -- **Credential.** The node returns an **opaque, node-generated** session token (a bearer secret with no client-parseable structure) alongside the record list. The client presents it on every subsequent `GET /v1/record/`, `GET /v1/proof/`, and (when the session was opened by an **OwnershipProof**) `GET /v1/account/state` in an `Authorization: Bearer ` header. The token is **not** a capability the client can mint, narrow, or forge — it only references server-side session state. +- **Credential.** The node returns an **opaque, node-generated** session token (a bearer secret with no client-parseable structure) alongside the record list. The client presents it on every subsequent `GET /v1/record/`, `GET /v1/proof/`, (when the session was opened by an **OwnershipProof**) `GET /v1/account/state`, and (v2 only) every `/v2/groups*` route in an `Authorization: Bearer ` header. The token is **not** a capability the client can mint, narrow, or forge — it only references server-side session state. A v1 pull session does not admit group routes. - **Expiry.** The session carries its **own** expiry, **independent of** the 60-second challenge `nonce` window (§5.1) — RECOMMENDED a few minutes. The node **MUST** reject a token past its session expiry (`410`). - **Binding (fail-closed).** The session state records the `chan_bind` ([§5.1](#51-capability-gated-pull)), the authenticated `subject`, and the **resolved (intersected) `scope`** of the `POST /v1/pull` that created it. A follow-up request is served **only** if it arrives over a channel whose recomputed `chan_bind` matches the session's (the same host/onion binding as the original proof — a token captured and replayed against a **different** node fails, exactly as a replayed proof does), and it releases a Private record **only** for a `record_id` / coin whose subject is the authenticated `subject` **and** which falls inside the session's resolved `scope` (including `SelfDeliveryRecordV1` state records, [§4.2](#42-bundle-delivery)). A token whose `chan_bind` does not match, whose `subject`/`scope` would be exceeded, or which is expired or unknown **MUST** be rejected — the node never widens disclosure beyond what the originating `POST /v1/pull` authorised. -The pull session is a transport convenience over the **same** authorisation the challenge–response already established; it grants no access the `OwnershipProof`/`GrantProof` did not, and it is the "still-valid pull session" referenced by `GET /v1/record/`, `GET /v1/proof/`, `GET /v1/account/state` (ownership sessions **only**), `GET /v1/receipts/stream` ([§7.5](#75-node-rest-api-normative)), and the `GetRecord` / `GetCoinProof` / `GetAccountState` / `SubscribeReceipts` kernel procedures ([§7.8](#78-kernel-rpc--the-internal-interface-normative)) — ownership **and** grant sessions are both admissible on the receipts stream and on record/proof fetch; `GET /v1/account/state` / `GetAccountState` admit **ownership sessions only** (a grant session is `401 unauthorized` — no full-state disclosure under a scoped grant). +The pull session is a transport convenience over the **same** authorisation the challenge–response already established; it grants no access the `OwnershipProof`/`GrantProof` did not, and it is the "still-valid pull session" referenced by `GET /v1/record/`, `GET /v1/proof/`, `GET /v1/account/state` (ownership sessions **only**), (v2 only) `/v2/groups*` (ownership sessions **only**), `GET /v1/receipts/stream` ([§7.5](#75-node-rest-api-normative)), and the `GetRecord` / `GetCoinProof` / `GetAccountState` / `SubscribeReceipts` / (v2 only) group-chat kernel procedures ([§7.8](#78-kernel-rpc--the-internal-interface-normative)) — ownership **and** grant sessions are both admissible on the receipts stream and on record/proof fetch; `GET /v1/account/state` / `GetAccountState` and every `/v2/groups*` / group-chat procedure (v2 only) admit **ownership sessions only** (a grant session is `401 unauthorized` — no full-state or group-state disclosure under a scoped grant). A v1 pull session does not admit group routes. ### 5.2 View grant @@ -2295,7 +2296,7 @@ flowchart TB |---|---|---|---| | **App · Explorer** | end-user wallet UI (NIP-05 receive identity §4.3, push receipts §4.9) · public explorer web-app | `zk-coins/app` · `zk-coins/explorer` | presentation; the app holds keys on-device, the explorer holds none | | **SDK** | thin client — on-device client-side primitives (key derivation, hashing, signing), node/API calls | `zk-coins/sdk` | custody stays on the device; REST + stream client | -| **zkCoins API** (+ own PostgreSQL) | the sole public REST surface; features `wallet`, `explorer`, `publisher`, and the two bridges, each switched on by the operator | `zk-coins/api` | present whenever a wallet or explorer is served; owns a **non-value-bearing** database | +| **zkCoins API** (+ own PostgreSQL) | the sole public REST surface; features `wallet`, `explorer`, `publisher`, and the two bridges, each switched on by the operator. `group_chat` is a **v2 feature — NOT applicable in v1** | `zk-coins/api` | present whenever a wallet or explorer is served; owns a **non-value-bearing** database | | **zkCoins node** (+ PostgreSQL + Publisher) | the trustless **kernel**, gRPC only: scan · accumulator · verify · prove · store · publisher/broadcaster · Nostr transport | `zk-coins/node` | the trustless core; owns the **value-bearing** database (§4.8) | | **bitcoind · Nostr relay** | Bitcoin L1 settlement and ordering · off-chain transport and data availability | upstream (own or external) | inherits Bitcoin's trust; transport trusted only for availability (§4.1) | | **Docker · OS · Hardware** | container runtime, host operating system, physical machine | — | the operational substrate the operator provides | @@ -2322,7 +2323,7 @@ Two programs, two decisions. The **kernel** (`zk-coins/node`) is one binary whos A node operator decides whether to run the kernel alone or the kernel with an API in front of it. An API operator decides, independently, which of the features below to serve. -**Wire advertisement (normative):** `GET /v1/info` ([§7.5](#75-node-rest-api-normative)) carries a `features` array whose elements are drawn from the **closed** set `{wallet, explorer, publisher, lightning_bridge, mail_bridge}`, listing exactly what this API has enabled. Unknown values **MUST** be ignored on read; a feature absent from the array is **off**, and a client **MUST** treat it as absent rather than attempting it (fail-closed). The kernel reports its own configured parts through the kernel `Info` message ([§7.8](#78-kernel-rpc--the-internal-interface-normative)); that report is operator-internal and reaches a public client only as far as `/v1/info` relays it. +**Wire advertisement (normative):** `GET /v1/info` ([§7.5](#75-node-rest-api-normative)) carries a `features` array whose elements are drawn from the **closed** v1 set `{wallet, explorer, publisher, lightning_bridge, mail_bridge}`, listing exactly what this API has enabled. `group_chat` is a **v2 feature — NOT applicable in v1**; a v1 producer **MUST NOT** emit it, and a v1 consumer **MUST** ignore it if seen (unknown-value rule below). Unknown values **MUST** be ignored on read; a feature absent from the array is **off**, and a client **MUST** treat it as absent rather than attempting it (fail-closed). The kernel reports its own configured parts through the kernel `Info` message ([§7.8](#78-kernel-rpc--the-internal-interface-normative)); that report is operator-internal and reaches a public client only as far as `/v1/info` relays it. **Kernel parts** (`zk-coins/node`, gRPC only): @@ -2332,6 +2333,7 @@ A node operator decides whether to run the kernel alone or the kernel with an AP | Nostr transport: bundle delivery, standard NIP-17 messaging on kind-10050 DM relays addressed by key, known-contact pinning ([§4.3](#43-addressing-for-delivery), [§7.3](#73-nostr-event-kinds-normative)) | **Core** | always on | | **Prover** — builds the recursive validity proofs | **Core** *if* this deployment proves at all | on / off | | **Publisher** — half-aggregates collected transition nullifiers and inscribes them ([§3.4](#34-the-publisher)) | optional | **off** | +| Marmot/MLS group-chat client — credentials, epoch state, kinds 444/445/30443 ([Group chat](/group-chat)) | **v2 only** | **not in v1** — a v1 kernel **MUST NOT** run this part | **API features** (`zk-coins/api`, the public REST surface). Each is off until the operator enables it, and each is named in the `features` array above: @@ -2343,6 +2345,8 @@ A node operator decides whether to run the kernel alone or the kernel with an AP | `lightning_bridge` | Lightning ⇄ zkCoins swaps at the operator edge ([extension](/lightning-bridge)) | **off** | | `mail_bridge` | SMTP interop for the account's NIP-05 identifier ([extension](/mail-bridge)) | **off** | +`group_chat` is a **v2 feature — NOT applicable in v1** ([Group chat](/group-chat)). It is **not** a member of the v1 closed `features` set. A v1 API **MUST NOT** advertise it. + A few standard **deployments** follow: - **Sovereign personal** — kernel with prover on, plus an API serving `wallet` for the owner's own account and nothing else. No foreign accounts, no publisher, no bridges. This is the private default. @@ -2505,11 +2509,12 @@ Before it signs, the wallet fetches the current authoritative state from its nod **Selecting the latest state under multiple verifying answers.** Multi-node fan-out can return **more than one** answer that verifies — typically because the queried nodes are at different sync states (each holds a valid snapshot of the lineage at a different `send_counter`). The wallet **MUST** select as authoritative "latest" the answer with the **highest `send_counter`** among those that qualify, before signing the next transition: a candidate **qualifies** when every **state-advancing** transition in its lineage — sends, receives, and mints included — is anchored, its on-chain nullifier in state `completed` ([§3.10](#310-transaction-states)), each nullifier's key bound to its transition's `consumed_pubkey` (the in-circuit clause 1 (iii) / clause 10(d) binding, [§2.1 clause 9](#21-the-compliance-predicate), re-run by the candidate's own recursive proof, so no fresh-key substitution qualifies). A mint transition ([§2.3.1](#231-mint--issuance)) is **not** exempt: it publishes its own `(Pkᵢ, Rᵢ)` and must reach `completed` like any other transition before the candidate qualifies; its validity is additionally attested by the candidate's own recursive proof. Two verifying answers with the **same** `send_counter` but **different** `new_account_state_hash` are an account-level fork — the SPEND-key holder signed two parallel transitions at the same counter. A wallet that detects this **MUST NOT** sign a further transition until the user resolves it, because sole legitimate control of `sk₀` and `skᵢ` never produces equivocation; detection here means either operator error (the same seed driven from two wallet instances against stale state) or a custody breach of the SPEND branch. The protocol does **not** automatically pick a fork-winner; the choice is the holder's. When **no** candidate qualifies (e.g. every recent spend is still within finality), the wallet builds the next transition against the highest-counter candidate whose state-advancing transitions are **final** (`nav = size_final`, §2.3.2 step 5); a not-yet-final candidate is simply not yet spendable, so the wallet waits; deployments handling extreme value **SHOULD** wait for `completed` before extending. -**Three portability residuals — the honest scope of "no node-specific value-bearing state".** [Requirement 10](/requirements)'s *"no node-specific value-bearing state"* covers the state a wallet needs to keep transacting: keys and the accumulator are seed-/chain-derived, while coin and state bundles are content-addressed, replicated, fetchable, and independently verifiable. None is tied to one node, so the switch and multi-node paths above carry **no value-state** migration step. Three **non-value-bearing** residuals are worth naming; none is a lock-in or a custody break: +**Four portability residuals — the honest scope of "no node-specific value-bearing state".** [Requirement 10](/requirements)'s *"no node-specific value-bearing state"* covers the state a wallet needs to keep transacting: keys and the accumulator are seed-/chain-derived, while coin and state bundles are content-addressed, replicated, fetchable, and independently verifiable. None is tied to one node, so the switch and multi-node paths above carry **no value-state** migration step. Four **non-value-bearing** residuals are worth naming; none is a lock-in or a custody break: - **Grant revocation is node-local and best-effort.** A view-grant revocation set lives on the node(s) the subject instructs ([§5.2](#52-view-grant)): "a node a subject does not control cannot be compelled … grants **SHOULD** carry a short `expiry`" (§5.2). On a node switch or in multi-node operation the subject **MUST** re-issue each still-active revocation to the new or additional nodes — a property of the best-effort revocation channel, not of node-specific *value* state; the coins and their spendability port regardless. - **An abandoned node keeps its view.** A node that held the account's **operational bundle** `{ivk, ovk, op, nk, op_secret}` retains a permanent incoming-receive-and-decrypt view of the account after the wallet switches away, because the account's viewing keys cannot be rotated without moving to a **new account** ([§5.8](#58-address-view-full-history)). This is a **privacy** residual ([Requirement 2](/requirements)) — the old operator can still decrypt coins later sent to that address — not a custody break or a lock-in: the switch itself is complete, spend authority never left the wallet ([Requirement 5](/requirements)), and the new node serves the account fully. - **Known-contact continuity uses local state.** The retained §4.3 contact record — keyed by the contact's pinned `op_pubkey`, with its known names, standard `nprofile`, last valid kind-10050 DM relays, optional pinned payment identity, and original-hostname-authenticated TLS/WebSocket endpoints — is not seed- or chain-derived. Switching nodes or recovering from the seed alone **without transferring that record** therefore resets trust-on-first-use and loses DNS-free cold-start reachability for that contact; the contact must be rediscovered when DNS returns. Transferring the record is **local deployment behavior, not a zkCoins or Nostr wire protocol**, so this specification states only what a client must not do with what it holds: a partial record **MUST NOT** be presented as providing DNS-free continuity, an existing pin **MUST NOT** be silently discarded or replaced, and a client that has lost its pin set **MUST NOT** silently re-pin a previously known contact as if it were a first contact ([§4.3](#43-addressing-for-delivery) *Payment-identity pinning*). A deployment that wants continuity across a node switch **SHOULD** transfer the complete record through an authenticated operator-controlled facility. None of this affects custody or spendability. +- **Marmot group state is residual operational state (v2 only).** When protocol v2 has activated `group_chat`, MLS leaf secrets, HPKE init keys, epoch secrets, and `group_event_key` live only on the node that holds the operational bundle. Leaf and HPKE-init material are CSPRNG-generated; epoch secrets and `group_event_key` come from the MLS key schedule. None of it is seed- or chain-derived. Switching nodes or recovering from seed + Bitcoin **does not** reconstruct group membership or history. Losing that state loses groups, never coins. Marmot application messages are the sole [Requirement 12](/requirements#12-data-permanence) / [§4.8](#48-durability--the-store-everything-invariant) carve-out; commits and proposals stay permanent. A deployment that wants group continuity across a node switch **MUST** transfer the MLS client credential store as local operational state. The MLS group id **MUST NOT** appear on the public REST surface ([Group chat](/group-chat)). ### 6.4 External interfaces (abstract) @@ -2526,7 +2531,7 @@ The node exposes six interface families, specified here at an implementation-neu The `read.account` path is **capability-gated**: a node **MUST** reject a request that does not present a valid ownership proof or `op`-signed view grant. Bearer view secrets (`zkview`/`zkavk`) and balance attestations are **not** node authorisations — the explorer applies them client-side to bundle blobs obtained from a blob store or a holder, so `explorer.read` widens only what the secret-holder can decrypt from already-public material ([Access & Explorer §5.1](#51-capability-gated-pull)). The `submit.tx` path needs no capability because the submitted transition carries its own validity proof and self-authenticating `SpendRecord`; a node **MUST** verify that proof before publishing. -**Which surface belongs to which program.** The families above split cleanly. **relay.\*** is the kernel's own transport plane — the Nostr relay (event delivery, `detect_tag` discovery, NIP-17 messaging by key, contact pinning) and the co-located Blossom blob store (the content-addressed `CoinProof` blob fetch) — and runs whether or not an API exists. Everything else in the table is REST and therefore reaches a client only through the API layer ([§6.1](#61-components-and-responsibilities)): `read.account` and `read.proof` under the `wallet` feature, `submit.tx` under `wallet`, the publisher hand-off ([§7.6](#76-publisher-interface-normative)) under `publisher`, and the public chain projection an explorer reads under `explorer`. `explorer.read` stays what it was — bearer view secrets applied client-side to already-public material, never a node authorisation. `read.provenance` is the one open, unauthenticated REST read (issuer-originated token provenance, [§4.6](#46-data-availability) Class B) — it discloses no Private data and needs no capability, and — unlike the capability-gated REST surfaces — it is **not** gated by a `features` flag: any API instance that has captured a token's `asset_terms` **MUST** serve them to any requester ([§4.6](#46-data-availability)), so this read is never answered `404 feature_disabled` and is served independently of `wallet`/`explorer`. Resolving names, and issuing them for served accounts, is API-layer work under `wallet`. A client learns what an instance serves from the closed `features` array on `GET /v1/info` and **MUST** treat anything absent from it as absent. +**Which surface belongs to which program.** The families above split cleanly. **relay.\*** is the kernel's own transport plane — the Nostr relay (event delivery, `detect_tag` discovery, NIP-17 messaging by key, contact pinning) and the co-located Blossom blob store (the content-addressed `CoinProof` blob fetch) — and runs whether or not an API exists. Everything else in the table is REST and therefore reaches a client only through the API layer ([§6.1](#61-components-and-responsibilities)): `read.account` and `read.proof` under the `wallet` feature, `submit.tx` under `wallet`, `/v2/groups*` under the **v2** `group_chat` feature (requires `wallet`; [Group chat](/group-chat); a v1 API **MUST NOT** serve these routes), the publisher hand-off ([§7.6](#76-publisher-interface-normative)) under `publisher`, and the public chain projection an explorer reads under `explorer`. `explorer.read` stays what it was — bearer view secrets applied client-side to already-public material, never a node authorisation. `read.provenance` is the one open, unauthenticated REST read (issuer-originated token provenance, [§4.6](#46-data-availability) Class B) — it discloses no Private data and needs no capability, and — unlike the capability-gated REST surfaces — it is **not** gated by a `features` flag: any API instance that has captured a token's `asset_terms` **MUST** serve them to any requester ([§4.6](#46-data-availability)), so this read is never answered `404 feature_disabled` and is served independently of `wallet`/`explorer`. Resolving names, and issuing them for served accounts, is API-layer work under `wallet`. A client learns what an instance serves from the closed `features` array on `GET /v1/info` and **MUST** treat anything absent from it as absent. ### 6.5 Issuance — token standards @@ -2631,6 +2636,8 @@ Custody is **cryptographically safe in every configuration**: no node holds a SP **Nostr messaging follows `op` custody.** The component holding `op` can decrypt incoming NIP-17 messages and can sign outgoing messages as that Nostr identity. With an own node this is under the user's control; a hosted provider holding the operational bundle can read messages, impersonate the account on Nostr, and publish a new kind-0 advertising a different `zkcoins` payment identity under the same `op_pubkey` — what bounds the damage is not an inability to spend (`op` genuinely cannot spend zkCoins) but the [§4.3](#43-addressing-for-delivery) payment-identity pin, under which a changed `address`, `pk0`, `nk_commit`, or `ivpk` for an already-pinned contact warns the sender rather than being adopted silently. Ordinary external NIP-17 correspondents never receive `op`; they interoperate through the public key and standard events alone. This is the existing operational-bundle trust boundary, not an additional bridge trust assumption. +**Group messaging follows MLS-client custody, which is the holder of `op` (v2 only).** When protocol v2 has activated `group_chat`, the same node holds the MLS client credential store next to the operational bundle. A hosted provider who holds `op` can decrypt group application messages and impersonate the account in groups. MLS leaf, HPKE, and epoch secrets **MUST NOT** be derived from `op`, `op_secret`, `nk`, or SPEND. Self-hosting is the private path ([Group chat](/group-chat)). + **Send-intent integrity is a correctness property, not custody.** "Cannot forge a signature, double-spend, or spend without your key" above is precise about **custody**: a node without the SPEND key can never produce a valid `txn_sig`, so it can never move a coin unilaterally, in every configuration. It does not mean every field a node proposes for the wallet's cooperative signature is independently checked by the wallet. For a send, the sole prover — in both the **own node** and **single foreign node** rows above — chooses the witness, including which `output_templates[]` (hence which `output_coins_root`) it builds the proof from, before the wallet ever sees it ([§7.5](#75-node-rest-api-normative)). Because the thin wallet runs no Poseidon (the thin-client rule), it cannot recompute `output_coins_root` from the templates it posted and so signs the node-reported `ocr` on trust that the node proved the templates it was given, not others ([§7.5](#75-node-rest-api-normative)). A dishonest or compromised single foreign node can therefore redirect a send's outputs to a party of its choosing, or drop an output — including the per-asset change coin — entirely (burn), within one cooperative signature — a correctness failure of the same kind this section already asks a foreign operator to be trusted for, not a break of the custody guarantee (no signature is forged, no coin moves without the wallet's key), but its effect on the sender is the same as theft. Self-hosting, or using only a node vetted for correctness and not merely liveness, is the only mitigation this design offers; see [Risks](/risks). **Node building blocks — own vs external.** Independently of the wallet↔node choice above, a node operator also chooses where its `bitcoind` and its `nostr-relay` come from ([§6.1](#61-components-and-responsibilities)). Running both yourself is the sovereign default. Pointing the node at an **external `bitcoind`** trades privacy (that node sees your chain queries) and raises eclipse exposure (the inherited assumption below), but **cannot** affect custody or correctness beyond that eclipse exposure — the node still re-verifies every inscription, bundle, and proof against its Bitcoin chain view ([Requirement 4](/requirements) via §6.2), and an external `bitcoind` can distort only that chain view, which the inherited ≥1-honest-peer assumption bounds. Using **external relay(s)** for transport sits on the same spectrum as any foreign relay: trusted only for availability and metadata-minimisation, never for correctness or custody (§4.1). Both are deliberate trade-offs, not new trust roots. @@ -2659,8 +2666,8 @@ How this architecture maps to the [Requirements](/requirements) at a glance: | **8 · Multi-asset** | `asset_id` plus the version-bound token standards — `IssuanceTerms_v1` (uncapped) and `IssuanceTerms_v2` (auditable capped supply, `cap_total`) — let anyone create their own asset; the creator is the sole minter (§6.5). | | **9 · Selective disclosure** | Three opt-in disclosure tiers, each verifiable against Bitcoin: a single transaction via a per-coin `K_tx` (§5.6), a history-private balance attestation (§5.7), and a full-history account view grant (§5.8); rendered by a self-hostable, stateless explorer (§6.1). | | **10 · Node portability** | No node-specific value-bearing wallet state; switch and multi-node by configuration alone for custody and transacting, with non-value portability residuals explicit (§6.3). | -| **11 · Standard identity and messaging** | Identity keys are seed-derived (§1.2); standard NIP-17/kind-10050 messaging on that key, interoperable with ordinary Nostr clients (§4.1, §7.3). The app and API layers give every account they serve one email-style name and the signed payment object bound to it; the holder attests the name with `sk₀` (`name_sig`), and the name is replaceable and survivable on loss (§4.3). Lightning and SMTP bridges remain independent and optional. | -| **12 · Data Permanence** | A node never deletes received data: every artefact is stored completely and retained indefinitely; no drop, expiry, pruning, or supersession clean-up (§4.8). Availability follows from that permanence plus the §4.3 recovery-discoverable overlap (event on ≥1 `seed_relay`, blob in ≥1 `blob_store`); further redundant copies only add durability (§4.6). Revocation ceases use of an operational bundle while the stored copy remains retained, marked revoked (§7.7). | +| **11 · Standard identity and messaging** | Identity keys are seed-derived (§1.2); standard NIP-17/kind-10050 messaging on that key, interoperable with ordinary Nostr clients (§4.1, §7.3). The app and API layers give every account they serve one email-style name and the signed payment object bound to it; the holder attests the name with `sk₀` (`name_sig`), and the name is replaceable and survivable on loss (§4.3). Lightning and SMTP remain independent and optional. Marmot/MLS group chat is a **v2 feature — NOT applicable in v1**. | +| **12 · Data Permanence** | A node never deletes received data: every artefact is stored completely and retained indefinitely; no drop, expiry, pruning, or supersession clean-up (§4.8), except Marmot MLS application messages when `group_chat` is on **(v2 only — NOT applicable in v1)** ([Group chat](/group-chat)). Availability follows from that permanence plus the §4.3 recovery-discoverable overlap (event on ≥1 `seed_relay`, blob in ≥1 `blob_store`); further redundant copies only add durability (§4.6). Revocation ceases use of an operational bundle while the stored copy remains retained, marked revoked (§7.7). | | **13 · Recovery availability from the seed alone** | A wallet that lost everything but its seed rebuilds its full state, because the §4.3 recovery-discoverable overlap places every delivery event on ≥1 network `seed_relay` and every blob in ≥1 network `blob_store` (§4.3/§4.5); the guarantee is operational (a reachable bootstrap node, ≥1 live holder per plane, and continuity-preserving manifest rotation, §4.3), not cryptographic, and a token survives its issuer via the open Class-B provenance read (§4.6/§7.5). | *(This table is the architecture summary; the [Requirements traceability](#requirements-traceability) table at the top of this page is the canonical requirement→mechanism map.)* @@ -2733,13 +2740,19 @@ zkCoins uses the account's `op` key as its ordinary Nostr identity ([§4.1](#41- | `1059` | NIP-59 gift wrap | regular | outer envelope (ephemeral key), as NIP-59 | | `13` | NIP-59 seal | regular | inner seal, as NIP-59 | | `14` | NIP-17 direct-message rumor | (rumor — unsigned, inside the seal) | human chat message, exactly as NIP-17 | -| `10050` | NIP-17 DM relay list | replaceable | recipient's preferred relays for NIP-17 messages | +| `10050` | NIP-17 DM relay list | replaceable | recipient's preferred relays for NIP-17 messages; reused unchanged as the Marmot Welcome inbox when v2 `group_chat` is on | +| `10002` | NIP-65 relay list | replaceable | write-capable relay set for KeyPackage fetch; required when v2 `group_chat` is on; otherwise unused by this protocol | +| `444` | Marmot Welcome rumor | (rumor — unsigned, inside the seal) | MLS Welcome; **inert in v1**; in v2, inert unless `group_chat` is on ([Group chat](/group-chat)) | +| `445` | Marmot group message | regular | encrypted MLS group message; **inert in v1**; in v2, inert unless `group_chat` is on | +| `30443` | Marmot KeyPackage | addressable | public KeyPackage slot, authored by `op_pubkey`; **inert in v1**; in v2, inert unless `group_chat` is on | | `1420` | zkCoins delivery rumor | (rumor — unsigned, inside the seal) | the `DeliveryEvent.payload` of [§4.2](#42-bundle-delivery) | | `1421` | zkCoins ACK rumor | (rumor — inside the seal) | the acknowledgement of [§4.2](#42-bundle-delivery) ACK rule | | `30421` | zkCoins publisher profile | addressable | `{version, relays}` — v1 publishing is sponsored, so no payment identity and no `addr_sig` ([§3.8](#38-fees-and-economics)); `op`-signed, `d` tag = hex `op_pubkey`, `pubkey` = that same key | | `30422` | zkCoins operator endpoint | addressable | signed `OperatorEndpointV1` of [§4.3](#43-addressing-for-delivery) (global infrastructure only); `d` tag = hex `operator_id` | | `30423` | zkCoins bootstrap manifest | addressable | signed `BootstrapManifestV1` of [§4.3](#43-addressing-for-delivery); `d` tag = network tag | +Kinds `444`, `445`, `30443`, and `10002` are **inert in v1**; in v2, inert unless the kernel `group_chat` part is on. A v1 kernel **MUST** ignore them for group-chat processing and **MUST NOT** mutate group state from them. A v2 kernel that does not run that part **MUST** ignore them for group-chat processing and **MUST NOT** mutate group state from them. The API feature only opens the REST door. Wire shapes, custody, REST, and negative controls live on [Group chat](/group-chat). The one-to-one NIP-17 profile below is unchanged. + **User metadata (kind 0).** An account served by the app or API layers **MUST** publish a standard kind-0 event authored and signed by its `op` key, and its JSON `content` **MUST** carry the account's normalized name in `nip05` — that is what lets any ordinary Nostr client complete the reverse check and reach the account by name ([§4.3](#43-addressing-for-delivery)). The `nip05` value is the account's one name in force ([§4.3](#43-addressing-for-delivery) *One active name*), and the `zkcoins` object's `name_sig` attests it; a consumer accepts that name and rejects one the profile does not attest. The `content` **MUST** also carry the §4.3 `zkcoins` object — that is what makes the account payable by its name — and **MAY** carry any other standard Nostr metadata field. A profile without a valid object is not payable through the profile, which never affects the name or NIP-17 messaging; an ordinary Nostr account that is not a zkCoins wallet has no object at all. When present, the object has exactly the fields `{version, network, address, pk0, nk_commit, ivpk, relays, addr_sig, name_sig}`: `version` is the JSON number `1`; `network` is one of `mainnet`, `testnet`, or `regtest`; `address` is Bech32m; `pk0`, `nk_commit`, and `ivpk` are lowercase hex of exactly 32 bytes; `relays` is a non-empty JSON array of relay URL strings used for kinds 1420/1421; and `addr_sig` and `name_sig` are each lowercase hex of exactly 64 bytes — `name_sig` over the profile's `nip05` value per [§4.3](#43-addressing-for-delivery) *Name consent*. `op_pubkey` is the kind-0 author and is not duplicated in the object. `lud16` is emitted only when the optional Lightning bridge is enabled for that name, and **MUST** then equal the profile's normalized `nip05` value ([Lightning bridge](/lightning-bridge)). Like the rest of a kind-0 profile, it is **not** gift-wrapped — it is intentionally public so any sender can discover it — but its disclosure is limited to the payment fields an `Invoice` would also carry, plus the `nip05` identifier and the object's `version`/`network` ([§4.3](#43-addressing-for-delivery)). **Profile verification — two checklists (normative).** A consumer runs the one its discovery path supports. @@ -2840,23 +2853,41 @@ where `blob_id = H(body)` — the content address of [§4.2.1](#421-bundle-blob- ### 7.5 Node REST API (normative) -This is the **public, outward** surface — what a wallet, SDK, or explorer speaks. It is served by the **API layer** on top of the kernel RPC (§7.8), and only by it: the kernel speaks gRPC and is never reachable from a public client ([§6.1](#61-components-and-responsibilities) *Kernel and API — two boundaries*). Each Private, submit, and chain endpoint below maps to a kernel-RPC procedure (§7.8); the trivial `GET /` (listing) and `GET /health` (liveness) endpoints are API-local and need no kernel call. Which endpoints an instance actually serves follows from its enabled `features` ([§6.1](#61-components-and-responsibilities)); a request against a disabled feature **MUST** be answered `404 feature_disabled` — the sole exception is the open `GET /v1/token//provenance` read, which is not `features`-gated and is served whenever the node holds the terms ([§6.4](#64-external-interfaces-abstract), [§4.6](#46-data-availability) Class B). +This is the **public, outward** surface — what a wallet, SDK, or explorer speaks. It is served by the **API layer** on top of the kernel RPC (§7.8), and only by it: the kernel speaks gRPC and is never reachable from a public client ([§6.1](#61-components-and-responsibilities) *Kernel and API — two boundaries*). Each Private, submit, and chain endpoint below maps to a kernel-RPC procedure (§7.8); the trivial `GET /` (listing) and `GET /health` (liveness) endpoints are API-local and need no kernel call; `GET /v1/networks` is also API-local and needs no kernel call. Which endpoints an instance actually serves follows from its enabled `features` ([§6.1](#61-components-and-responsibilities)); a request against a disabled feature **MUST** be answered `404 feature_disabled`, subject to the **Error precedence** rule in *Multi-network projection* — the sole exception is the open `GET /v1/token//provenance` read, which is not `features`-gated and is served whenever the node holds the terms ([§6.4](#64-external-interfaces-abstract), [§4.6](#46-data-availability) Class B). + +All paths are relative to the node base URL and MUST be served over TLS 1.3/1.2 or a Tor v3 onion service ([§5.1](#51-capability-gated-pull)). Errors use HTTP status + a JSON `{ "error": "", "message": "" }` body — **except** the readiness probe's **200/503** answers (`GET /v1/{network}/health/ready`, and the single-network **MAY** `GET /health/ready`), which use the `{ ready: bool, reason? }` shape below. A `404 network_unavailable` on the prefixed readiness path **is** the generic `{ "error", "message" }` body. Idempotent mutating requests (`submit`) MUST honour an `Idempotency-Key` request header. The key is an opaque client string of at most 64 bytes; a node MUST retain the mapping `Idempotency-Key → job_id` and the associated request hash (body fingerprint) **indefinitely** under Data Permanence ([§4.8](#48-durability--the-store-everything-invariant)) — no TTL, no expiry, and no deletion of persisted idempotency data. A known key **always** yields the same outcome with no time bound: a repeat of the same key with a byte-identical body returns the original `202 { job_id }`; the same key with a different body is rejected `409 idempotency_conflict`. -All paths are relative to the node base URL and MUST be served over TLS 1.3/1.2 or a Tor v3 onion service ([§5.1](#51-capability-gated-pull)). Errors use HTTP status + a JSON `{ "error": "", "message": "" }` body — **except** `GET /health/ready`, which uses its own readiness shape below and is **not** an instance of this generic error body. Idempotent mutating requests (`submit`) MUST honour an `Idempotency-Key` request header. The key is an opaque client string of at most 64 bytes; a node MUST retain the mapping `Idempotency-Key → job_id` and the associated request hash (body fingerprint) **indefinitely** under Data Permanence ([§4.8](#48-durability--the-store-everything-invariant)) — no TTL, no expiry, and no deletion of persisted idempotency data. A known key **always** yields the same outcome with no time bound: a repeat of the same key with a byte-identical body returns the original `202 { job_id }`; the same key with a different body is rejected `409 idempotency_conflict`. +**Multi-network projection (normative).** A deployment serves one or more networks in **parallel**. Each `kernel.v1` upstream (§7.8) **MUST** be single-network — its `Info.network` names exactly one of `mainnet`, `testnet`, `regtest` — and the **API layer MAY multiplex** several single-network upstreams behind one public origin so a client can reach every served network at once (never a redeploy between them). The network is then a **mandatory path segment**: every **network-dependent** endpoint of §§7.5–7.7 — this section, the publisher interface `/v1/publish/…` ([§7.6](#76-publisher-interface-normative)), and the bootstrap endpoints `/v1/bootstrap/…` ([§7.7](#77-wallet--node-bootstrapping-normative)) — **MUST** be served under the prefix `/v1/{network}/` (e.g. `/v1/mainnet/info`, `/v1/testnet/tx`, `/v1/mainnet/publish/spendrecord`, `/v1/testnet/bootstrap/entrust`), where `{network}` is one of the served network names. No other network-dependent endpoint is exempt; the **only** endpoints that **MUST** be served origin-local (without the prefix) are `GET /`, `GET /health`, the network-less `GET /v1/networks`, and the content-addressed `/blossom/…` plane (constrained below). A single-network deployment **MAY** additionally accept the same endpoints **without** the prefix; that unprefixed form is a **distinct served route**, not a fallback — the no-default rule below governs the `{network}` segment of the prefixed form. The following rules are normative and **fail-closed**: + +- **Path inventory (normative).** Every network-dependent path in the tables of §§7.5–7.7 is written in the unprefixed `/v1/` form (`/v1/info`, `/v1/tx`, `/v1/publish/spendrecord`, …). That form is the **logical path**. A deployment **MUST** serve each such path as `/v1/{network}/` (example: `/v1/info` → `/v1/mainnet/info`, never `/v1/mainnet/v1/info`). A single-network deployment **MAY** additionally serve the unprefixed logical path as a distinct route. Origin-local paths (`GET /`, `GET /health`, `GET /v1/networks`, `/blossom/…`) are never rewritten. The per-network readiness probe is `GET /v1/{network}/health/ready`; unprefixed `GET /health/ready` is the single-network **MAY** form of that probe, not an origin-local **MUST**. +- **Network states.** A configured network is **admitted** once its `/v1/{network}/health/ready` has first answered `{ ready: true }` — its [Path to mainnet](/implementation-mandate) readiness gate has passed (testnet **within** step 7, mainnet **within** step 9 at deployment; this "ready to serve" point precedes and is distinct from step 10 activation, and a network **MUST** be discoverable before it can be transacted against). Thereafter a served network is, at any moment, either **ready** (`/v1/{network}/health/ready` = `{ ready: true }`) or **not-ready** (`{ ready: false, reason }` for one closed `reason ∈ {syncing, scanner_lag, deep_reorg, circuit_mismatch, dependency_unavailable}`). The origin-local `GET /health` reflects only the multiplexing process's own liveness and **MUST NOT** report per-network readiness. +- **Routing by state (fail-closed).** A request whose `{network}` the deployment does **not** serve (unknown or not configured) **MUST** be answered `404 network_unavailable`; there is **no** default network, and an omitted or unknown `{network}` **MUST NOT** fall back to another. A request to a **served but not-ready** network's **enabled** consensus-dependent endpoints — including a configured network not yet **admitted** — **MUST** be answered `503 network_not_ready`, the specific `reason` being available on that network's `/v1/{network}/health/ready`. `GET /v1/{network}/health/ready` itself **MUST** answer for every served network (ready, or not-ready with its `reason`) and `404 network_unavailable` for an unserved one. The `404` and `503` conditions **MUST NOT** be conflated. +- **Error precedence (normative).** When more than one of `network_unavailable`, `feature_disabled`, and `network_not_ready` could apply, the API **MUST** classify the request in this order; the first match wins and the others **MUST NOT** be emitted: + 1. Resolve `{network}`. If the deployment does not serve it (unknown or not configured) → `404 network_unavailable`. Feature checks do not run — there is no network whose `features` apply. + 2. If the target endpoint is a `features`-gated surface of that served network and the feature is off → `404 feature_disabled`. `GET /`, `GET /health`, `GET /v1/networks`, `GET /v1/{network}/info`, `GET /v1/{network}/health/ready`, and the `/blossom/…` plane are **not** `features`-gated for this test (the open provenance read remains ungated as already specified). + 3. If the endpoint is an **enabled** consensus-dependent surface and the network is not ready (including not-yet-admitted) → `503 network_not_ready`. + 4. Otherwise serve the endpoint. +- **Discovery.** `GET /v1/networks` (network-less) **MUST** return `{ networks: [ { network: , base_path: "/v1/" } ] }` listing **exactly** the **admitted** networks (admission, **not** live readiness): an admitted network that is transiently not-ready **MUST** stay listed, and **MUST** be removed **only** when it is no longer configured as served or its upstream identity check (below) fails. `GET /` **MUST** carry the additional closed key `networks` resolving to this endpoint. `GET /v1/{network}/info` **MUST** return exactly the `/v1/info` object defined below, with its `network` field equal to `{network}`. +- **Root-listing templates.** In a multi-network deployment every **network-scoped** value in the `GET /` `endpoints` map (below) **MUST** be a URI template containing the literal `{network}` path variable (e.g. `/v1/{network}/tx`) and **MUST NOT** resolve to one concrete network; the origin-local keys (`health`, `networks`) and the content-addressed Blossom keys (`blossom_get`, `blossom_head`, `blossom_upload`) **MAY** resolve to concrete paths/origins. +- **Upstream identity (anti-confusion).** Before serving or advertising a `{network}`, the API **MUST** verify the mounted upstream's `Info.network` (§7.8) **equals** `{network}`; on mismatch it **MUST NOT** serve or list that network. This closes the mis-wired `mainnet → testnet-kernel` (or any cross-network) routing hole. +- **Strict network scoping.** Custody, addresses, coins, capability challenges/sessions and their nonce stores, the `Idempotency-Key → job_id` store, and account records are **per-network**: the API **MUST NOT** serve a record, session, challenge, or idempotency mapping created under one network in response to a request under another. Each network's kernel already holds its own value store (§7.8); the API's own non-value-bearing stores **MUST** be keyed by network. +- **Blossom plane.** Blossom blobs are content-addressed and network-agnostic in content. A single origin-local `/blossom/…` plane **MAY** be shared across served networks **only** when its admission role, `max_blob_bytes`, and quota policy are deployment-global and identical for every served network; otherwise each network's `blossom_url` (its `/v1/{network}/info`) **MUST** identify a **distinct origin**, and that distinct origin **is** the network scoping — the [§7.4](#74-blossom-blob-store-normative) authorization event, which authorizes an upload to the specific origin it is sent to, is unchanged. **Public (unauthenticated) — the Public projection of [§5.5](#55-two-explorer-modes); no Private data:** | Method | Path | Returns | |---|---|---| -| `GET` | `/` | `{ name, version, endpoints }` — `endpoints` is an object mapping each **closed** logical name below to an absolute URL or a root-relative path (UTF-8 string values only; unknown keys ignored on read; a conforming producer emits exactly the closed key set for the surfaces this deployment exposes, and **MUST** omit keys for unadvertised optional roles). **Closed keys** (all §7.4 / §7.5 surfaces): `health`, `health_ready`, `info`, `chain_accumulator`, `chain_inscriptions`, `chain_nullifier`, `tx`, `jobs`, `jobs_stream`, `jobs_sign`, `jobs_cancel`, `attest_balance_challenge`, `attest_balance`, `grants_challenge`, `grants`, `pull_challenge`, `pull`, `record`, `proof`, `account_state`, `receipts_stream`, `publish_spendrecord`, `bootstrap_challenge`, `bootstrap_entrust`, `bootstrap_revoke`, `blossom_get`, `blossom_head`, `blossom_upload`, `token_provenance` | +| `GET` | `/` | `{ name, version, endpoints }` — `endpoints` is an object mapping each **closed** logical name below to an absolute URL or a root-relative path (UTF-8 string values only; unknown keys ignored on read; a conforming producer emits exactly the closed key set for the surfaces this deployment exposes, and **MUST** omit keys for unadvertised optional roles). **Closed keys** (v1 §7.4 / §7.5 surfaces): `health`, `health_ready`, `networks`, `info`, `chain_accumulator`, `chain_inscriptions`, `chain_nullifier`, `tx`, `jobs`, `jobs_stream`, `jobs_sign`, `jobs_cancel`, `attest_balance_challenge`, `attest_balance`, `grants_challenge`, `grants`, `pull_challenge`, `pull`, `record`, `proof`, `account_state`, `receipts_stream`, `publish_spendrecord`, `bootstrap_challenge`, `bootstrap_entrust`, `bootstrap_revoke`, `blossom_get`, `blossom_head`, `blossom_upload`, `token_provenance`. The seven keys `groups`, `group`, `group_messages`, `group_invites`, `group_members_remove`, `group_leave`, and `group_keypackages` are **v2** and a v1 producer **MUST** omit them. | | `GET` | `/health` | `200 "ok"` once the process is up | -| `GET` | `/health/ready` | readiness probe — **both** success and not-ready answers use the **same** object `{ ready: bool, reason?: }` (and **MAY** carry diagnostic fields `bitcoin_tip_height`, `root`, `size`, `scanner_lag` when known, where `root = nav_root = Hc("NfLog/Root", size ‖ mth)` (§3.7)). **HTTP 200** with `{ ready: true }` (no `reason`) when the node is ready to serve consensus-dependent reads and credits; **HTTP 503** with `{ ready: false, reason: }` otherwise. Closed `reason ∈ {syncing, scanner_lag, circuit_mismatch, deep_reorg, dependency_unavailable}` — a node **MUST** use exactly one of these values and **MUST NOT** invent further codes. This endpoint is **explicitly excluded** from the generic `{ "error", "message" }` error-body shape above | -| `GET` | `/v1/info` | `{ network, protocol_version: "v1", circuit_digests: { C, C_balance }, bootstrap_pubkey: , relay_url, blossom_url, max_blob_bytes, finality_confirmations: 6, activation_height: , max_tx_inputs: 8, max_tx_outputs: 8, max_rx_coins: 4, max_account_assets: 32, features: [], bootstrap: BootstrapManifestV1 }` — `network ∈ {mainnet, testnet, regtest}` is the sole network tag (v1 pins Bitcoin network 1:1 to this tag; there is **no** separate `bitcoin_network` field). `bootstrap_pubkey` is the network-parameter pin of [§3.6](#36-chain-scanning) / [§4.3](#43-addressing-for-delivery) under which `bootstrap.manifest_sig` **MUST** verify. `features` is the closed advertisement set of [§6.1](#61-components-and-responsibilities): each element ∈ `{wallet, explorer, publisher, lightning_bridge, mail_bridge}`, naming exactly what this API instance serves; a feature absent from the array is off and a client **MUST** treat it as absent. `bootstrap` mirrors the per-network signed [§4.3](#43-addressing-for-delivery) manifest (seed relays, blob stores, operator IDs only; **no** account- or blob-specific fields) | +| `GET` | `/v1/networks` | `{ networks: [ { network: , base_path: "/v1/" } ] }` listing **exactly the admitted** networks (admission, not live readiness — [§7.5 *Multi-network projection*](#75-node-rest-api-normative)). API-local; no kernel call | +| `GET` | `/health/ready` | **single-network MAY** form of the readiness probe — **both** success and not-ready answers use the **same** object `{ ready: bool, reason?: }` (and **MAY** carry diagnostic fields `bitcoin_tip_height`, `root`, `size`, `scanner_lag` when known, where `root = nav_root = Hc("NfLog/Root", size ‖ mth)` (§3.7)). **HTTP 200** with `{ ready: true }` (no `reason`) when the node is ready to serve consensus-dependent reads and credits; **HTTP 503** with `{ ready: false, reason: }` otherwise. Closed `reason ∈ {syncing, scanner_lag, circuit_mismatch, deep_reorg, dependency_unavailable}` — a node **MUST** use exactly one of these values and **MUST NOT** invent further codes. The readiness probe's **200/503** answers use this shape and are **not** the generic `{ "error", "message" }` error body | +| `GET` | `/v1/{network}/health/ready` | same readiness object as `/health/ready`. **HTTP 200** `{ ready: true }` or **HTTP 503** `{ ready: false, reason }` for every **served** network; **HTTP 404** `{ "error": "network_unavailable", "message": "…" }` (generic body) when `{network}` is not served. This is the mandatory multi-network readiness probe | +| `GET` | `/v1/info` | `{ network, protocol_version: "v1", circuit_digests: { C, C_balance }, bootstrap_pubkey: , relay_url, blossom_url, max_blob_bytes, finality_confirmations: 6, activation_height: , max_tx_inputs: 8, max_tx_outputs: 8, max_rx_coins: 4, max_account_assets: 32, features: [], bootstrap: BootstrapManifestV1 }` — `network ∈ {mainnet, testnet, regtest}` is the sole network tag (v1 pins Bitcoin network 1:1 to this tag; there is **no** separate `bitcoin_network` field). `bootstrap_pubkey` is the network-parameter pin of [§3.6](#36-chain-scanning) / [§4.3](#43-addressing-for-delivery) under which `bootstrap.manifest_sig` **MUST** verify. `features` is the closed advertisement set of [§6.1](#61-components-and-responsibilities): each element ∈ `{wallet, explorer, publisher, lightning_bridge, mail_bridge}`, naming exactly what this API instance serves; a feature absent from the array is off and a client **MUST** treat it as absent. `group_chat` is a **v2 feature — NOT applicable in v1**. `bootstrap` mirrors the per-network signed [§4.3](#43-addressing-for-delivery) manifest (seed relays, blob stores, operator IDs only; **no** account- or blob-specific fields) | | `GET` | `/v1/chain/accumulator` | `{ size, root, tip_block_hash, tip_height }` — the current `NAV(tip)` as `(size, root)` with `root = nav_root = Hc("NfLog/Root", size ‖ mth)` (§3.7) ([§3.7](#37-the-nullifier-accumulator); NAV(tip) is `(size, mth)` now) | | `GET` | `/v1/chain/inscriptions?from_height=&from_tx_index=&from_vin_index=&limit=` | paginated list of zkCoins nullifier inscriptions. **Query (normative):** `from_height` optional, default `0`; `from_tx_index` optional, default `0`; `from_vin_index` optional, default `0` — together the **inclusive** lexicographic lower bound on the reveal input's `(height, tx_index, vin_index)` triple ([§3.5](#35-inscription-format)/[§3.6](#36-chain-scanning): a reveal transaction **MAY** carry multiple inscriptions in different `vin_index` values); `limit` optional, default `100`, valid range `1..1000` — `limit = 0` or `limit > 1000` → `400 bounds_exceeded`. **Response:** `{ inscriptions: [ { txid, height, tx_index, vin_index, count, format, nullifiers: [{ pubkey, r, state }], confirmation_state } ], next_height?: , next_tx_index?: , next_vin_index?: }` — each inscription carries its reveal-input `vin_index`; the half-aggregated `(Pkⱼ, Rⱼ)` set of each inscription ([§3.5](#35-inscription-format)), whose signatures the node has verified against Bitcoin; each `nullifiers[i].state` is that member's §3.10 state (`completed` \| `pending` \| `failed`) — members of one aggregate MAY differ by first-occurrence (a later `Pk` collision is `failed` while earlier members stay `pending`/`completed`); `confirmation_state ∈ {pending, completed}` is **only** the reveal transaction's confirmation depth relative to the §3.9 6-confirmation floor (not a per-nullifier aggregate of §3.10) and **MUST NOT** carry `failed`. **Stable sort (normative):** ascending `(height, tx_index, vin_index)` of the reveal input (then the §3.6 payload-member order inside one inscription). **Pagination cursor (normative):** when more results remain, **all three** of `next_height`, `next_tx_index`, and `next_vin_index` are present and form the **exclusive** triple-cursor after the last returned inscription — the `(height, tx_index, vin_index)` of the first not-yet-returned inscription in sort order; a client requests the next page with `from_height = next_height`, `from_tx_index = next_tx_index`, and `from_vin_index = next_vin_index` (so a page that ends mid-transaction does **not** duplicate or skip remaining `vin_index` inscriptions of that reveal). When no further results remain, **all three** cursor fields are **absent**. A response **MUST NOT** carry a proper subset of the three cursor fields | | `GET` | `/v1/chain/nullifier/` | a self-verifying **RFC-6962 inclusion** proof for the account-state key ` = Pkᵢ` against the current accumulator log when present (the Path-B service of [§3.7](#37-the-nullifier-accumulator)): `{ present, position?: , leaf?: , audit_path: [hex] (≤ 64), tree_size: , root, tip_block_hash, tip_height }` — when `present: true`, `position` / `leaf` / `audit_path` form an authenticated inclusion proof of `(Pkᵢ, Rᵢ)` at its position; when `present: false`, the body is an **unauthenticated local-index absence answer** (the node's `Pk → (pos, R)` index has no entry) — **not** an RFC-6962 non-inclusion proof and **MUST NOT** back a credit (`tip_block_hash` 32B, internal order) | -**Accumulator `root` (normative).** In **every** §7 surface — `GET /v1/chain/accumulator`, `GET /health/ready`, `GET /v1/chain/nullifier/`, and the kernel `AccumulatorTip` / `NullifierPath` — the field `root` denotes the **byte-unique** committed value `nav_root = Hc("NfLog/Root", size ‖ mth)` (§3.7), always paired with its `size`; it is **never** the bare Merkle-tree-head `mth`. +**Accumulator `root` (normative).** In **every** §7 surface — `GET /v1/chain/accumulator`, `GET /v1/{network}/health/ready` (single-network **MAY**: `GET /health/ready`), `GET /v1/chain/nullifier/`, and the kernel `AccumulatorTip` / `NullifierPath` — the field `root` denotes the **byte-unique** committed value `nav_root = Hc("NfLog/Root", size ‖ mth)` (§3.7), always paired with its `size`; it is **never** the bare Merkle-tree-head `mth`. **Submit & proving (no capability — the proof is self-authenticating, [§6.4](#64-external-interfaces-abstract)):** @@ -2890,7 +2921,15 @@ TransitionRequest = { fee_address : , // deferred (§3.8.1): MUST be absent in v1; presence matrix below fold_coin_ids : [ ], // kind == "receive": required, 1..max_rx_coins // (§2.1 clause 10); MUST be absent otherwise + genesis_pubkey : , // kind == "receive" only: required when the receive + // runs as an InitialProof — the account's first + // transition, prev_proof absent (§2.1 clause 1, §2.3.3); + // MUST be absent for an AccountUpdateProof and for + // kind ∈ {"mint","send"} issuance : { // kind == "mint": required; MUST be absent otherwise + creator_pubkey : , // required; the creator's base pubkey Pk₀ — a + // domain-separated input to asset_id (§6.5); the + // node returns it from GET /v1/token//provenance name : , // (§1.5; name_hash = H(name), §1.4) decimals : , issuance_version : 1 | 2, // any other value is malformed (§2.1 clause 3) @@ -2919,6 +2958,8 @@ DeliveryCredential = // (author, created_at, Nostr signature) ``` +**`creator_pubkey` and `genesis_pubkey` — base-pubkey carriers (normative).** `issuance.creator_pubkey` is **required** for every `kind == "mint"`; it carries the creator's base pubkey Pk₀, which hashes into `asset_id` as a domain-separated input ([§6.5](#65-issuance--token-standards)) and which the open `GET /v1/token//provenance` read later returns to a caller. `genesis_pubkey` is **required** for a genesis receive — a `kind == "receive"` transition the node runs as an **InitialProof**: the account's first transition, with `prev_proof` absent and `prev_account_state` the canonical empty account ([§2.1 clause 1](#21-the-compliance-predicate), [§2.3.3](#233-receive)) — and supplies the genesis account's initial `txn_pubkey`; it **MUST** be absent for an `AccountUpdateProof` receive and for `kind ∈ {"mint","send"}`. A missing required field, a field present where forbidden, or a value not exactly 32 bytes is malformed: the node **MUST** reject the request with `400 malformed_request` and **MUST NOT** start a job, as for every other `TransitionRequest` presence-rule violation below. These fields are [§1.7.8](#178-reference-instantiation-status-final-for-v1) **between-step-3-and-step-7** additive §7 wire changes: `creator_pubkey` is already a bound circuit input that hashes into `asset_id`, so its wire field only transports a value the circuit already consumes; `genesis_pubkey` transports the initial `txn_pubkey` the account model already binds rather than introducing a new circuit input. Neither field changes a circuit element, a pinned vector, or a digest, and neither moves a trust boundary. The same clause names the open `GET /v1/token//provenance` read as the stated precedent for this class of addition, so neither field creates a new protocol version. + **`delivery` — the delivery credential (normative).** This field is the wire carrier that closes the §4.3 gap: the node needs the recipient's `{ivpk, op_pubkey, relays}` to deliver (§4.2), and a bare address is not resolvable. It is a [§1.7.8](#178-reference-instantiation-status-final-for-v1) **between-step-3-and-step-7** wire addition: it touches **neither** a circuit element **nor** a pinned vector **nor** a digest, and therefore is **not** a new protocol version under that clause. Verification is **kernel-only** ([§6.1](#61-components-and-responsibilities)): the API layer forwards `delivery` **unchanged**, **MUST NOT** mark it verified, and **MUST NOT** log it (retention rule below). **Check-list `type: "invoice"` (normative).** The kernel **MUST** verify, in order: the three [§4.3](#43-addressing-for-delivery) checks — (i) `H(pk0 ‖ nk_commit) == invoice.recipient`; (ii) `addr_sig` valid under `pk0` over `invoice_message`; (iii) `sig` valid under `op_pubkey` over `invoice_message` — **and** byte-exact equality of `invoice.recipient`, `invoice.asset_id`, and `invoice.amount` with the enclosing `OutputTemplate`'s `recipient`, `asset_id`, and `amount`, **and** the structural invariants of the [§1.5](#15-core-data-structures)/[§4.3](#43-addressing-for-delivery) `Invoice`: fixed-width fields at their stated widths and at least one valid relay URL. Any failure is `400 malformed_request`. @@ -3014,7 +3055,7 @@ An `attest_balance` job (kind `"attest_balance"`) uses the **same** phase string | `wrong_phase` | `/sign` or `/cancel` was called while the job is not in the `status` that accepts it | | `proving_failed` | witness assembly or proof generation failed | | `publish_rejected` | the chosen publisher rejected the finalised `SpendRecord` ([§7.6](#76-publisher-interface-normative)); `message` carries the publisher's `reason` | -| `circuit_digest_mismatch` | the node's own build does not match the `circuit_digests` it advertises at `/v1/info` ([§1.7.9](#179-proof-system-parameters-normative)) | +| `circuit_digest_mismatch` | the request names or requires a circuit digest that does not match the `circuit_digests` advertised at `GET /v1/{network}/info` ([§1.7.9](#179-proof-system-parameters-normative)). **A conforming kernel that detects this about its own build is not-ready** (`reason: circuit_mismatch` on the readiness probe) and **MUST** emit `network_not_ready` on consensus-dependent procedures, not this code. This code remains in the closed set so an API layer maps it if a kernel emits it (V.9 N-27) | Additional codes (closing the enumeration across §7.4–§7.7 surfaces): @@ -3022,17 +3063,23 @@ Additional codes (closing the enumeration across §7.4–§7.7 surfaces): |---|---|---| | `malformed_request` | 400 | body violates a normative shape of this section (`TransitionRequest` presence rules, missing or failed `OutputTemplate.delivery` credential check, unknown `delivery.type`, §7.1 JSON rules, wrong-HRP Bech32m, non-hex where hex is required) | | `idempotency_conflict` | 409 | the same `Idempotency-Key` was replayed with a different body (mapping retained indefinitely; a known key never expires or is forgotten) | -| `unauthorized` | 401 | §5.1 capability invalid (bad signature/`chal`/grant), action-bound OwnershipProof missing/invalid/wrong-domain on `/v1/attest/balance` or `/v1/grants` (including a `GrantProof` presented where only owner auth is accepted — no-escalation), Blossom auth-event rejected, missing/invalid pull-session bearer token (absent or malformed — not merely expired), or a **grant** pull session presented to ownership-only `GET /v1/account/state` | +| `unauthorized` | 401 | §5.1 capability invalid (bad signature/`chal`/grant), action-bound OwnershipProof missing/invalid/wrong-domain on `/v1/attest/balance` or `/v1/grants` (including a `GrantProof` presented where only owner auth is accepted — no-escalation), Blossom auth-event rejected, missing/invalid pull-session bearer token (absent or malformed — not merely expired), or a **grant** pull session presented to ownership-only `GET /v1/account/state` or (v2 only) any `/v2/groups*` route | | `scope_exceeded` | 403 | §5.1 resolved-scope violation, non-peer PUT | +| `not_group_admin` | 403 | a non-admin called an admin-only group membership route (`invite`, `members/remove`) | | `challenge_expired` | 410 | pull/bootstrap/attest/grants `nonce` expired, already consumed, or unknown | -| `session_expired` | 410 | pull-session token expired, unknown, or presented over a channel whose `chan_bind` does not match (incl. `GET /v1/receipts/stream`, `GET /v1/record/`, `GET /v1/proof/`, and `GET /v1/account/state`) | -| `not_found` | 404 | unknown `blob_id`, `record_id`/`coin_id` outside the session's scope-visible set, unknown job (`job_not_found` stays canonical for the jobs family) | +| `session_expired` | 410 | pull-session token expired, unknown, or presented over a channel whose `chan_bind` does not match (incl. `GET /v1/receipts/stream`, `GET /v1/record/`, `GET /v1/proof/`, `GET /v1/account/state`, and (v2 only) `/v2/groups*`) | +| `not_found` | 404 | unknown `blob_id`, `record_id`/`coin_id` outside the session's scope-visible set, unknown job (`job_not_found` stays canonical for the jobs family), or unknown `group_id` | +| `feature_disabled` | 404 | API or kernel feature/part is off. v1: a request against a disabled member of the closed set `{wallet, explorer, publisher, lightning_bridge, mail_bridge}` ([§6.1](#61-components-and-responsibilities)). v2 additionally: API or kernel `group_chat` part is off (G-09), or `wallet` is off, while a `/v2/groups*` route is called. A v1 API that has no `/v2/groups*` surface still answers those paths 404 without serving group state. | +| `invitee_not_ready` | 409 | invitee has no valid kind-10050 list; Welcome is not published (G-10) | | `payload_too_large` | 413 | Blossom body over the advertised limit | | `rate_limited` | 429 | API-layer rate limit (operator policy, [§7.8](#78-kernel-rpc--the-internal-interface-normative)) | | `dependency_not_final` | 409 | a submitted transition depends on a nullifier position `≥ size_final` (not yet 6-confirmation-final) or on a fork-loser that can never finalize; the wallet must wait for finality (or abandon a fork-loser dependency) and resubmit | +| `network_unavailable` | 404 | the request's `/v1/{network}/` segment names a network this deployment does not serve (or it serves none); there is no default network and no fallback to another ([§7.5 *Multi-network projection*](#75-node-rest-api-normative)) | +| `feature_disabled` | 404 | the request targets a `features`-gated surface of a **served** network whose feature is off ([§6.1](#61-components-and-responsibilities), [§7.5 *Multi-network projection*](#75-node-rest-api-normative) *Error precedence*); API-only — an unserved `{network}` is `network_unavailable` and never reaches this check | +| `network_not_ready` | 503 | the target network is served but its upstream is not ready to answer this consensus-dependent request; the specific reason (`syncing` \| `scanner_lag` \| `deep_reorg` \| `circuit_mismatch` \| `dependency_unavailable`) is on its `GET /v1/{network}/health/ready` ([§7.5 *Multi-network projection*](#75-node-rest-api-normative)) | | `internal_error` | 500 | any condition not covered by a listed code | -HTTP status for the earlier jobs-family table: `invalid_input_coin`, `insufficient_balance`, `bounds_exceeded`, `unknown_publisher` → `400` (rejected at submit) or, when detected only during proving, they appear as the terminal job `error`; `stale_message`, `invalid_signature`, `wrong_phase` → `409`; `job_not_found` → `404`; `proving_failed`, `publish_rejected` → terminal job `error` objects (the job poll itself returns `200`); `circuit_digest_mismatch` → `503`. `202` is the only success status for `POST /v1/tx`; `200` for every other success. +HTTP status for the earlier jobs-family table: `invalid_input_coin`, `insufficient_balance`, `bounds_exceeded`, `unknown_publisher` → `400` (rejected at submit) or, when detected only during proving, they appear as the terminal job `error`; `stale_message`, `invalid_signature`, `wrong_phase` → `409`; `job_not_found` → `404`; `proving_failed`, `publish_rejected` → terminal job `error` objects (the job poll itself returns `200`); `circuit_digest_mismatch` → `503`; `network_not_ready` → `503`. `202` is the only success status for `POST /v1/tx`; `200` for every other success. The `GET /v1/jobs//stream` `complete`/`error` frames (above) carry the same `result`/`error` objects; the `phase` frame carries `awaiting_signature` inline in its `data` once `phase == "awaiting_signature"`. @@ -3047,6 +3094,22 @@ The `GET /v1/jobs//stream` `complete`/`error` frames (above) carry the s | `GET` | `/v1/account/state` | **ownership-gated** authoritative account-state read (realises the state half of [§6.4](#64-external-interfaces-abstract) `read.account`; kernel `GetAccountState`, [§7.8](#78-kernel-rpc--the-internal-interface-normative)): served **only** within a still-valid [pull session](#pull-session-normative) opened by an **OwnershipProof** (`Authorization: Bearer `; same pull-session pattern as `GET /v1/proof/`). Returns JSON `{ account_state: , state_head: , head_record_id?: , send_counter: , current_pubkey: , last_nullifier?: { pubkey: , r: } }` — `send_counter` / `current_pubkey` **MUST** equal the corresponding fields inside `account_state`; `last_nullifier` is the head transition's on-chain nullifier `(Pk, R)` when the account has advanced at least once, else omitted (canonical empty account, [§2.2](#22-proof-types)). **Auth (normative, fail-closed, same split as pull):** missing/invalid bearer (absent `Authorization`, malformed token, or a **grant** session presented here) → `401 unauthorized`; unknown, expired, or `chan_bind`-mismatching session → `410 session_expired`; never collapse expiry/unknown/channel-mismatch into `401` | | `GET` | `/v1/receipts/stream` | Server-Sent Events: one receipt event per credited coin inside the pull session's stored `subject` + resolved `scope` (ownership **or** grant session; `Authorization: Bearer `; missing/invalid token → `401`, unknown/expired/`chan_bind`-mismatch → `410 session_expired`) — the public front of `kernel.v1` `SubscribeReceipts` ([§7.8](#78-kernel-rpc--the-internal-interface-normative)) and the [§4.9](#49-real-time-push-delivery) push source | +**Group chat (v2 feature — NOT applicable in v1; gated on `group_chat` + `wallet`; [Group chat](/group-chat)).** Normative for protocol v2. A **v1** API **MUST NOT** serve these routes. Crypto stays node-side. Auth remains a still-valid **ownership** pull session (`Authorization: Bearer `), exactly as `GET /v1/account/state` (the v1 pull-session mechanism); a GrantProof session **MUST** be rejected as `401 unauthorized`. An expired, unknown, or `chan_bind`-mismatching token is `410 session_expired`. No SPEND. When v2 activates, the API **MUST NOT** advertise `group_chat` unless `wallet` is also advertised. Check order **MUST** be: (1) this is a v1 API, or API `group_chat` or `wallet` off → `404 feature_disabled` without a kernel call; (2) session checks → `401` / `410`; (3) kernel `group_chat` part off → `404 feature_disabled`. HTTP 200 on a publishing route means durable persist plus the first publication attempt to every snapshotted target, not a relay `OK`. `group_id` is a node-local stable id; the MLS group id **MUST NOT** appear on this surface. + +| Method | Path | Body / Returns | +|---|---|---| +| `GET` | `/v2/groups` | `{ groups: [ { group_id, nostr_group_id, epoch, role, title } ] }` — API **MUST omit** `members`/`relays` | +| `POST` | `/v2/groups` | body `{ title }` → `{ group_id, nostr_group_id }` | +| `GET` | `/v2/groups/:group_id` | members, epoch, routing relays | +| `POST` | `/v2/groups/:group_id/messages` | body `{ content }` — plaintext UTF-8 string | +| `GET` | `/v2/groups/:group_id/messages` | decrypted application texts the node has already processed | +| `POST` | `/v2/groups/:group_id/invites` | body `{ op_pubkey }` of the invitee → `{ invited: true }` | +| `POST` | `/v2/groups/:group_id/members/remove` | body `{ op_pubkey }` — admin-only remove → `{ removed: true }`; non-admin → `403 not_group_admin` | +| `POST` | `/v2/groups/:group_id/leave` | leave | +| `POST` | `/v2/groups/keypackages` | body `{ d?: }` — omit to create; present `d` MUST be 64-char hex (empty → `400 malformed_request`); unknown `d` → `404 not_found` | + +Shapes, encodings, and fail-closed rules are normative on [Group chat](/group-chat). + **Open token provenance ([§4.6](#46-data-availability) Class B, [§6.5](#65-issuance--token-standards)):** | Method | Path | Body / Returns | @@ -3063,7 +3126,7 @@ An API layer running the `publisher` feature ([§6.1](#61-components-and-respons | Method | Path | Body / Returns | |---|---|---| -| `POST` | `/v1/publish/spendrecord` | body = `{ public_key: , r: , s: , r_prime: , fee_blob_id?: , fee_blob_locators?: , fee_epk?: , block_anchor: { block_hash: , height: } }` → on success or policy/crypto rejection: **HTTP 200** `{ accepted: bool, reason?: , batch_eta?: }`; on malformed wire body: **HTTP 400** `{ "error": "malformed_request", "message": "…" }`; on internal failure: **HTTP 500** `{ "error": "internal_error", "message": "…" }` — the transition's on-chain nullifier `(public_key = Pkᵢ, r = Rᵢ)` and its BIP-340 scalar `s = sᵢ`, plus `r_prime = R'`, the spender's **non-secret** pre-tweak sign-to-contract nonce point (symmetric to the `/sign` body of [§7.5](#75-node-rest-api-normative)) that opens the S2C tweak `Rᵢ = R' + H(bytes(R') ‖ H(ProofData))·G`, the fee-coin **ZBE delivery reference** (when present), and the freshness `block_anchor` | +| `POST` | `/v1/publish/spendrecord` | body = `{ public_key: , r: , s: , r_prime: , fee_blob_id?: , fee_blob_locators?: , fee_epk?: , block_anchor: { block_hash: , height: } }` → on success or policy/crypto rejection: **HTTP 200** `{ accepted: bool, reason?: , batch_eta?: }`; on malformed wire body: **HTTP 400** `{ "error": "malformed_request", "message": "…" }`; on a served-but-not-ready target network: **HTTP 503** `{ "error": "network_not_ready", "message": "…" }` ([§7.5](#75-node-rest-api-normative)); on internal failure: **HTTP 500** `{ "error": "internal_error", "message": "…" }` — the transition's on-chain nullifier `(public_key = Pkᵢ, r = Rᵢ)` and its BIP-340 scalar `s = sᵢ`, plus `r_prime = R'`, the spender's **non-secret** pre-tweak sign-to-contract nonce point (symmetric to the `/sign` body of [§7.5](#75-node-rest-api-normative)) that opens the S2C tweak `Rᵢ = R' + H(bytes(R') ‖ H(ProofData))·G`, the fee-coin **ZBE delivery reference** (when present), and the freshness `block_anchor` | **Fee-coin transport (deferred — not v1; [§3.8.1](#381-fee-coin-mechanism-deferred)).** A v1 node **MUST** reject any request carrying `fee_blob_id`, `fee_epk`, or `fee_blob_locators`, in whole or in part, with `400 malformed_request` and **MUST NOT** fetch, decrypt, or otherwise act on them; the rules below describe the deferred mechanism only. When a fee coin is present ([§7.5](#75-node-rest-api-normative) presence matrix case (b)), the fee `CoinProof` is delivered as an **ordinary ZBE blob encrypted to the publisher's `fee_address` `IVPK`** under the same note-key / ZBE path as every other recipient coin ([§1.3](#13-per-coin-keys-note-encryption--detection), [§4.2](#42-bundle-delivery), [§4.2.1](#421-bundle-blob-encryption-zbe-normative), [§3.8](#38-fees-and-economics) step 3). The publish body carries **only**: @@ -3082,6 +3145,7 @@ In v1 the fee fields are always absent — publishing is sponsored ([§3.8](#38- | Wire body violates the normative shape (wrong types/widths, unknown fields that break decoding, partial fee-field set, `block_anchor.height` outside `u32`, non-hex where hex is required, etc.) | **400** | `{ "error": "malformed_request", "message": "…" }` — same §7.5 error-body shape; **no** `{accepted:false}` | | Body is well-formed but the hand-off is cryptographically or by policy rejected | **200** | `{ accepted: false, reason: }` | | Hand-off accepted | **200** | `{ accepted: true, batch_eta: }` | +| Target network served but not ready to publish ([§7.5](#75-node-rest-api-normative) readiness) | **503** | `{ "error": "network_not_ready", "message": "…" }` — same §7.5 error-body shape; **no** `{accepted:false}` | | Internal publisher failure | **500** | `{ "error": "internal_error", "message": "…" }` | There is **no** auth-gated rejection code on this permissionless endpoint. @@ -3151,7 +3215,7 @@ A node MUST verify `chan_bind` and `chal` for these two endpoints exactly as [§ | Procedure | Kind | Purpose | Backs | |---|---|---|---| -| `GetInfo` | unary | network, `protocol_version`, `circuit_digests`, `bootstrap_pubkey`, finality + bounds, `features`, sync/`ready` (+ `ready_reason` when not ready) | `GET /v1/info`, `/health/ready` | +| `GetInfo` | unary | network, `protocol_version`, `circuit_digests`, `bootstrap_pubkey`, finality + bounds, `features`, sync/`ready` (+ `ready_reason` when not ready) | `GET /v1/{network}/info`, `GET /v1/{network}/health/ready` (single-network **MAY**: `GET /v1/info`, `GET /health/ready`) | | `GetAccumulator` | unary | current `{ size, root, tip_block_hash, tip_height }` | `GET /v1/chain/accumulator` | | `ListInscriptions` | server-stream | zkCoins nullifier inscriptions from a `(height, tx_index, vin_index)` cursor (paginated; defaults `from_height=0`, `from_tx_index=0`, `from_vin_index=0`, `limit=100`) | `GET /v1/chain/inscriptions` | | `GetNullifierPath` | unary | Path-B answer for an account-state key `Pkᵢ`: RFC-6962 **inclusion** when present, else unauthenticated local-index absence (`present: false` — not an RFC-6962 non-inclusion proof; [§3.7](#37-the-nullifier-accumulator)) | `GET /v1/chain/nullifier/` | @@ -3172,8 +3236,17 @@ A node MUST verify `chan_bind` and `chal` for these two endpoints exactly as [§ | `AttestBalance` | unary | start a `C_balance` proving job for a balance attestation for an already-authorised **owner** (the API layer runs the action-bound OwnershipProof gate of [§5.1](#51-capability-gated-pull)/[§7.5](#75-node-rest-api-normative); GrantProof **MUST** be rejected upstream) | `POST /v1/attest/balance` | | `IssueViewGrant` | unary | sign a [§5.2](#52-view-grant) grant with the account's `op` key for an already-authorised **owner** (API-layer OwnershipProof gate; **no-escalation** — a grant delegate must not reach this procedure) | `POST /v1/grants` | | `GetTokenProvenance` | unary | issuer-originated `IssuanceTerms` for an `asset_id` the node has captured (open — the API layer runs **no** capability gate for this one; [§4.6](#46-data-availability) Class B) | `GET /v1/token//provenance` (§7.5) | - -**`kernel.v1` message contract (normative).** The following Protocol-Buffers definition is the complete, normative `kernel.v1` contract. Conventions: every 32-byte protocol value is `bytes` and its length **MUST** be exactly 32 (a violation is `INVALID_ARGUMENT`); `block_anchor.height` is `uint32`, matching the on-chain 4-byte field ([§1.7.3](#173-fixed-widths)) — a value outside `[0, 2^32−1]` is `INVALID_ARGUMENT`; `u128` amounts are decimal strings (mirroring [§7.1](#71-serialization-conventions-normative)); timestamps are `uint64` Unix seconds; enumerated states use the literal §7.5 strings. The API layer performs the entire §5.1 capability gate ([§7.8 *Who enforces the capability gate*](#78-kernel-rpc--the-internal-interface-normative)); the kernel receives `chan_bind` only as an **opaque 32-byte equality token** to bind sessions — it never derives or interprets it. +| `ListGroups` | unary | local Marmot groups the kernel already holds for the authorised owner (**v2 only**) | `GET /v2/groups` | +| `CreateGroup` | unary | create a group; draw `nostr_group_id` (**v2 only**) | `POST /v2/groups` | +| `GetGroup` | unary | members, epoch, routing relays (**v2 only**) | `GET /v2/groups/:group_id` | +| `SendGroupMessage` | unary | encrypt and publish one application text (**v2 only**) | `POST /v2/groups/:group_id/messages` | +| `ListGroupMessages` | unary | decrypted application texts already processed (**v2 only**) | `GET /v2/groups/:group_id/messages` | +| `InviteGroupMember` | unary | fetch KeyPackage and publish Welcome (admin) (**v2 only**) | `POST /v2/groups/:group_id/invites` | +| `RemoveGroupMember` | unary | MLS remove commit (admin) (**v2 only**) | `POST /v2/groups/:group_id/members/remove` | +| `LeaveGroup` | unary | self-leave; allowed for `admin` and `member` (**v2 only**) | `POST /v2/groups/:group_id/leave` | +| `PublishKeyPackage` | unary | publish or rotate a kind-30443 slot (**v2 only**) | `POST /v2/groups/keypackages` | + +**`kernel.v1` message contract (normative).** The following Protocol-Buffers definition is the complete, normative `kernel.v1` contract. It **MUST NOT** contain the group-chat procedures. Those are recorded immediately after it as a `kernel.v2` preview. Conventions: every 32-byte protocol value is `bytes` and its length **MUST** be exactly 32 (a violation is `INVALID_ARGUMENT`); `block_anchor.height` is `uint32`, matching the on-chain 4-byte field ([§1.7.3](#173-fixed-widths)) — a value outside `[0, 2^32−1]` is `INVALID_ARGUMENT`; `u128` amounts are decimal strings (mirroring [§7.1](#71-serialization-conventions-normative)); timestamps are `uint64` Unix seconds; enumerated states use the literal §7.5 strings. The API layer performs the entire §5.1 capability gate ([§7.8 *Who enforces the capability gate*](#78-kernel-rpc--the-internal-interface-normative)); the kernel receives `chan_bind` only as an **opaque 32-byte equality token** to bind sessions — it never derives or interprets it. ```proto syntax = "proto3"; @@ -3217,18 +3290,24 @@ message Info { uint32 max_tx_outputs = 9; uint32 max_rx_coins = 10; uint32 max_account_assets = 11; - bool ready = 12; // backs /health/ready + bool ready = 12; // backs GET /v1/{network}/health/ready (single-network MAY /health/ready) uint64 bitcoin_tip_height = 13; bytes accumulator_root = 14; // = nav_root (§3.7) uint64 scanner_lag = 15; uint64 max_blob_bytes = 16; // §7.4 Blossom advertised size limit uint64 activation_height = 17; // pinned per-network scan origin (§3.6) BootstrapManifest bootstrap = 18; // §4.3 global infrastructure only - repeated string kernel_parts = 19; // which kernel parts this kernel runs: each ∈ - // {"scanner","prover","publisher"}. NOT the §7.5 /v1/info - // `features` array — that is API-layer configuration the API - // owns and constructs itself, and the kernel cannot know it. - optional string ready_reason = 20; // set iff ready == false; closed set (§7.5 /health/ready): + repeated string kernel_parts = 19; // which kernel parts this kernel runs. v1 closed set = + // {"scanner","prover","publisher"}. `group_chat` is a + // v2 kernel part — NOT applicable in v1. A v1 kernel + // MUST NOT emit it and MUST NOT run the Marmot client. + // When v2 activates: enabling API `group_chat` while + // this kernel part is off MUST answer `404 feature_disabled`. + // NOT the §7.5 /v1/info `features` array — that is + // API-layer configuration the API owns and constructs + // itself, and the kernel cannot know it. + optional string ready_reason = 20; // set iff ready == false; closed set (§7.5 GET /v1/{network}/health/ready, + // single-network MAY /health/ready): // "syncing" | "scanner_lag" | "circuit_mismatch" | "deep_reorg" // | "dependency_unavailable" bytes bootstrap_pubkey = 21; // 32B x-only; pinned network-parameter trust anchor for BootstrapManifest (§3.6, §4.3) @@ -3342,6 +3421,7 @@ message Issuance { string amount = 4; string cap_total = 5; // set iff issuance_version == 2 bytes terms_salt = 6; // set iff issuance_version == 2 + bytes creator_pubkey = 7; // Pk₀ (32B x-only); required — binds into asset_id (§6.5) } message TransitionRequest { string kind = 1; // "mint" | "send" | "receive" @@ -3353,6 +3433,7 @@ message TransitionRequest { bytes publisher_pubkey = 6; // empty ⇒ self-publish (case a); set ⇒ case (b) or (c) string fee_address = 7; // deferred (§3.8.1): MUST be empty in v1 (§7.5 matrix cases (a)/(c)) repeated bytes fold_coin_ids = 8; + bytes genesis_pubkey = 12; // recipient's Pk₀ (32B x-only); required for a genesis receive, absent otherwise (§2.3.3) Issuance issuance = 9; string idempotency_key = 10; // §7.5 Idempotency-Key pass-through } @@ -3411,6 +3492,10 @@ message PullRequest { Scope resolved_scope = 3; // the already-intersected scope (§5.1) — the kernel // trusts the API layer for ACCESS, never widens bytes chan_bind = 4; // opaque 32B equality token for session binding (§5.1) + string authority = 5; // closed: "ownership" | "grant"; which capability the API layer + // verified at §5.1 — the kernel records it on the session so + // GetAccountState can admit ownership only; retires the + // x-zkcoins-session-authority metadata key } message RecordRef { bytes record_id = 1; // opaque 32B id of this Private record @@ -3502,7 +3587,51 @@ message TokenProvenance { } ``` -A breaking change to any message or procedure is a new package (`kernel.v2`), never an in-place edit ([§1.7.8 v1 freeze](#178-reference-instantiation-status-final-for-v1)). +**`kernel.v2` group-chat preview (normative for protocol v2 — NOT applicable in v1).** Recorded here for continuity. A **v1** kernel **MUST NOT** include, generate, or expose these procedures. A **v1** API **MUST NOT** call them. + +```proto +syntax = "proto3"; +package kernel.v2; + +service KernelV2 { + rpc ListGroups(GroupAccountRequest) returns (GroupList); + rpc CreateGroup(CreateGroupRequest) returns (GroupHandle); + rpc GetGroup(GroupRequest) returns (GroupDetail); + rpc SendGroupMessage(SendGroupMessageRequest) returns (GroupMessageHandle); + rpc ListGroupMessages(GroupRequest) returns (GroupMessageList); + rpc InviteGroupMember(GroupMemberRequest) returns (GroupAck); + rpc RemoveGroupMember(GroupMemberRequest) returns (GroupAck); + rpc LeaveGroup(GroupRequest) returns (GroupAck); + rpc PublishKeyPackage(GroupAccountRequest) returns (KeyPackageHandle); +} + +// ownership pull session only — grant sessions are UNAUTHENTICATED/unauthorized (§7.5) +message GroupAccountRequest { string session = 1; bytes chan_bind = 2; string d = 3; } // d empty ⇒ new slot; set ⇒ rotate that slot +message CreateGroupRequest { string session = 1; bytes chan_bind = 2; string title = 3; } +message GroupHandle { string group_id = 1; string nostr_group_id = 2; } // nostr_group_id lowercase hex +message GroupRequest { string session = 1; bytes chan_bind = 2; string group_id = 3; } +message GroupMember { string op_pubkey = 1; string role = 2; } // role ∈ {"admin","member"} +message GroupDetail { + string group_id = 1; string nostr_group_id = 2; uint64 epoch = 3; + string role = 4; string title = 5; + repeated GroupMember members = 6; repeated string relays = 7; +} +message GroupList { repeated GroupDetail groups = 1; } // ListGroups MUST leave members and relays empty +message SendGroupMessageRequest { string session = 1; bytes chan_bind = 2; string group_id = 3; string content = 4; } +message GroupMessageHandle { string message_id = 1; } // recovered MLS message id, lowercase hex +message GroupMessage { + string message_id = 1; string sender_op_pubkey = 2; string content = 3; + uint64 created_at = 4; // inner unsigned application event created_at +} +message GroupMessageList { repeated GroupMessage messages = 1; } // MUST be sorted ascending (created_at, message_id) +message GroupMemberRequest { string session = 1; bytes chan_bind = 2; string group_id = 3; string op_pubkey = 4; } +message GroupAck { bool ok = 1; } +message KeyPackageHandle { string d = 1; string i = 2; } // slot id and KeyPackageRef, lowercase hex +``` + +**Wire-completeness fields (normative).** `Issuance.creator_pubkey = 7` and `TransitionRequest.genesis_pubkey = 12` ratify the base-pubkey values the reference node proto already carries, with the same presence rules as [§7.5](#75-node-rest-api-normative): `creator_pubkey` is required for a mint, while `genesis_pubkey` is required for a genesis receive and absent otherwise. A presence-rule violation, including the genesis-receive case, is `INVALID_ARGUMENT` / `malformed_request` / `400` under the `SubmitTransition` row of the per-procedure error table. `PullRequest.authority` is a closed string, `"ownership"` or `"grant"`, that carries which capability the API layer verified at [§5.1](#51-capability-gated-pull); the kernel records it on the pull session so `GetAccountState` can admit an ownership session only, as stated above under **Who enforces the capability gate.** Its absence or any other value is `INVALID_ARGUMENT` / `malformed_request` / `400` under the `Pull` row of the per-procedure error table. The field retires the out-of-contract `x-zkcoins-session-authority` gRPC metadata key: the discriminator is now a first-class part of the `kernel.v1` contract, so a client built from the `.proto` alone can open a session the kernel accepts. [§1.7.8](#178-reference-instantiation-status-final-for-v1) makes an addition to the §7 wire formats between runbook step 3 and step 7 — spanning both the §7.5 REST body and the §7.8 gRPC messages — not a new protocol version when it touches no circuit element, pinned vector, or digest, provided a specification PR states why; this amendment supplies that statement. `authority` has no circuit involvement, and the two pubkey fields transport values the circuit or account model already binds, as the §7.5 paragraph above explains. None changes a circuit element, a pinned vector, or a digest, and the API layer continues to perform the entire capability gate, so none moves a trust boundary. + +A breaking change to any message or procedure is a new package (`kernel.v2`), never an in-place edit ([§1.7.8 v1 freeze](#178-reference-instantiation-status-final-for-v1)). The `ListGroups` … `PublishKeyPackage` procedures are **normative for protocol v2** / `kernel.v2` (the `group_chat` kernel part). A **v1** kernel **MUST NOT** expose them. A **v1** API **MUST NOT** call them. When the v2 API feature is off, omit the REST keys and answer `/v2/groups*` as `404 feature_disabled` without calling the kernel. A v2 kernel whose `group_chat` part is off **MUST** reject those procedures as `NOT_FOUND` / `feature_disabled` / `404`. The API layer **MUST NOT** remap `ErrorInfo.reason`: it forwards `feature_disabled` as HTTP 404 with that machine_code. Failed v2 group procedures use `ErrorInfo.domain = "kernel.v2"` with the same reason/http_status mapping as `kernel.v1`. **Error contract (normative, closed, deterministic).** Every failed `kernel.v1` procedure returns a `google.rpc.Status` whose primary `code` is one of the eight gRPC codes below and whose `details` **MUST** include exactly one `google.rpc.ErrorInfo` with: @@ -3510,17 +3639,19 @@ A breaking change to any message or procedure is a new package (`kernel.v2`), ne - `ErrorInfo.domain` = `"kernel.v1"`; - `ErrorInfo.metadata["http_status"]` = the decimal HTTP status string that pins the REST mapping (e.g. `"400"`, `"401"`, `"410"`, `"503"`) — including the **410 special cases** `challenge_expired` / `session_expired`, which share gRPC `UNAUTHENTICATED` with `unauthorized` but **MUST NOT** collapse to HTTP 401. +When the v2 group procedures exist, the same contract applies with `ErrorInfo.domain = "kernel.v2"`. + The eight admissible gRPC codes and their §7.5 meaning classes: | gRPC `Status.code` | Meaning class | Typical §7.5 `machine_code`s | |---|---|---| | `INVALID_ARGUMENT` | malformed request, wrong types/widths, bounds exceeded | `malformed_request`, `bounds_exceeded`, `invalid_input_coin`, `insufficient_balance`, `unknown_publisher` | -| `NOT_FOUND` | unknown job / record / coin / blob outside the authorised set | `job_not_found`, `not_found` | -| `FAILED_PRECONDITION` | wrong phase, stale S2C, dependency not final, idempotency conflict, invalid signature at `/sign` | `wrong_phase`, `stale_message`, `invalid_signature`, `dependency_not_final`, `idempotency_conflict` | +| `NOT_FOUND` | unknown job / record / coin / blob outside the authorised set, or optional kernel part off | `job_not_found`, `not_found`, `feature_disabled` | +| `FAILED_PRECONDITION` | wrong phase, stale S2C, dependency not final, idempotency conflict, invalid signature at `/sign`, invitee not ready | `wrong_phase`, `stale_message`, `invalid_signature`, `dependency_not_final`, `idempotency_conflict`, `invitee_not_ready` | | `UNAUTHENTICATED` | missing/invalid capability or session (incl. grant-on-ownership-only) | `unauthorized`, `challenge_expired`, `session_expired` | -| `PERMISSION_DENIED` | resolved-scope violation | `scope_exceeded` | +| `PERMISSION_DENIED` | resolved-scope or group-admin violation | `scope_exceeded`, `not_group_admin` | | `RESOURCE_EXHAUSTED` | rate limit or payload size | `rate_limited`, `payload_too_large` | -| `UNAVAILABLE` | not ready / circuit-digest mismatch | `circuit_digest_mismatch` (and not-ready answers that surface as HTTP `503` on `/health/ready` with the readiness body `{ready:false, reason}`, **not** the generic error shape) | +| `UNAVAILABLE` | not ready / circuit-digest mismatch | `circuit_digest_mismatch`, `network_not_ready` (the readiness-probe **200/503** body is derived only from a successful `GetInfo` (`Info.ready` / `Info.ready_reason`) onto `GET /v1/{network}/health/ready` (and the single-network **MAY** `GET /health/ready`) — that body is **not** the generic error shape; every other consensus-dependent procedure emits generic `network_not_ready` / HTTP 503) | | `INTERNAL` | **only** conditions not covered by a listed code | `internal_error` | Terminal job outcomes that appear inside a successful `GetJob` / `StreamJob` payload (`proving_failed`, `publish_rejected` as `JobError.error`) are **not** gRPC Status failures of those procedures — the procedure itself returns `OK` with `Job.status ∈ {"failed","cancelled"}`. A procedure **MUST NOT** invent additional gRPC codes or `ErrorInfo.reason` values. **Server-streams** (`ListInscriptions`, `StreamJob`, `SubscribeReceipts`) that fail **MUST** end the stream with the **same** `google.rpc.Status` + `ErrorInfo` detail shape (no distinct stream-only error vocabulary). @@ -3529,29 +3660,40 @@ An independent API layer **MUST** map each kernel error onto the §7.5 REST surf **Per-procedure error table (normative).** Each procedure lists the `Status.code` / `ErrorInfo.reason` / `http_status` triples it **MAY** emit; a condition outside its row is `INTERNAL` / `internal_error` / `500`. Empty cells mean the procedure does not emit that class. +**Readiness enforcement (normative).** `GetInfo` **MUST** always succeed and return `Info` (including its `ready` / `ready_reason` fields) even when the network is not ready — the API derives both `/v1/{network}/info` (metadata) and `/v1/{network}/health/ready` (`200` when `ready`, else `503` carrying the `reason`) from that one always-succeeding call, and the upstream-identity check ([§7.5 *Multi-network projection*](#75-node-rest-api-normative)) needs `Info.network` on a not-ready upstream. For every **other** consensus-dependent procedure — the ones carrying `network_not_ready` / `503` below — the kernel **MUST** return `UNAVAILABLE` / `network_not_ready` / `503` while it is not ready to serve consensus-dependent reads and credits (a `GET /v1/{network}/health/ready` `reason ∈ {syncing, scanner_lag, deep_reorg, circuit_mismatch, dependency_unavailable}`). Because a `SubmitTransition` / `AttestBalance` job's consensus-dependent work (proof finalization, delivery, publishing, completion) runs **after** the accepting RPC returns, the kernel **MUST NOT** advance, deliver, publish, or complete a consensus-dependent job phase while not ready — it pauses that phase until the network is ready again (it never completes consensus-dependent work against a not-ready state). The same rule covers the permissionless `Publish` hand-off ([§7.6](#76-publisher-interface-normative)), which has no `job_id`: an **already-accepted** hand-off **MUST NOT** be half-aggregated or inscribed while the network is not ready — it is held in the publisher's batch queue until ready, then processed under the **unchanged** §7.6 accept/inscription and [§3.5](#35-inscription-format) `block_anchor`-freshness semantics (a held hand-off whose anchor has since gone stale is handled exactly as any stale batch — best-effort inscription is a pre-existing publisher property, not changed by multi-network). This makes the readiness gate **kernel-authoritative** for both synchronous reads and in-flight jobs (and the publisher's post-acceptance batch); an API-layer pre-dispatch check (reading `Info.ready`) is an optional optimisation. The API maps this triple to the REST `503 network_not_ready` body; the `404 network_unavailable` case is **API-only** (an unserved `{network}` never reaches a kernel) and is therefore **not** a kernel `ErrorInfo.reason`. + | Procedure | `INVALID_ARGUMENT` | `NOT_FOUND` | `FAILED_PRECONDITION` | `UNAUTHENTICATED` | `PERMISSION_DENIED` | `RESOURCE_EXHAUSTED` | `UNAVAILABLE` | `INTERNAL` | |---|---|---|---|---|---|---|---|---| -| `GetInfo` | — | — | — | — | — | — | not-ready → `503` (when used for `/health/ready`) | `internal_error`/`500` | -| `GetAccumulator` | — | — | — | — | — | — | not-ready → `503` | `internal_error`/`500` | -| `ListInscriptions` | `bounds_exceeded`/`400` (`limit` ∉ `1..1000`), `malformed_request`/`400` (non-numeric query) | — | — | — | — | `rate_limited`/`429` | not-ready → `503` | `internal_error`/`500` | -| `GetNullifierPath` | `malformed_request`/`400` (pubkey width) | — | — | — | — | `rate_limited`/`429` | not-ready → `503` | `internal_error`/`500` | -| `SubmitTransition` | `malformed_request`/`400`, `bounds_exceeded`/`400`, `invalid_input_coin`/`400`, `insufficient_balance`/`400`, `unknown_publisher`/`400` | — | `idempotency_conflict`/`409`, `dependency_not_final`/`409` | — | — | `rate_limited`/`429` | `circuit_digest_mismatch`/`503` | `internal_error`/`500` | +| `GetInfo` | — | — | — | — | — | — | — | `internal_error`/`500` | +| `GetAccumulator` | — | — | — | — | — | — | `network_not_ready`/`503` | `internal_error`/`500` | +| `ListInscriptions` | `bounds_exceeded`/`400` (`limit` ∉ `1..1000`), `malformed_request`/`400` (non-numeric query) | — | — | — | — | `rate_limited`/`429` | `network_not_ready`/`503` | `internal_error`/`500` | +| `GetNullifierPath` | `malformed_request`/`400` (pubkey width) | — | — | — | — | `rate_limited`/`429` | `network_not_ready`/`503` | `internal_error`/`500` | +| `SubmitTransition` | `malformed_request`/`400`, `bounds_exceeded`/`400`, `invalid_input_coin`/`400`, `insufficient_balance`/`400`, `unknown_publisher`/`400` | — | `idempotency_conflict`/`409`, `dependency_not_final`/`409` | — | — | `rate_limited`/`429` | `network_not_ready`/`503` | `internal_error`/`500` | | `GetJob` | `malformed_request`/`400` | `job_not_found`/`404` | — | — | — | `rate_limited`/`429` | — | `internal_error`/`500` | | `StreamJob` | `malformed_request`/`400` | `job_not_found`/`404` | — | — | — | `rate_limited`/`429` | — | `internal_error`/`500` | -| `SignTransition` | `malformed_request`/`400` (sig/`s2c_nonce` width) | `job_not_found`/`404` | `wrong_phase`/`409`, `stale_message`/`409`, `invalid_signature`/`409` | — | — | `rate_limited`/`429` | — | `internal_error`/`500` | +| `SignTransition` | `malformed_request`/`400` (sig/`s2c_nonce` width) | `job_not_found`/`404` | `wrong_phase`/`409`, `stale_message`/`409`, `invalid_signature`/`409` | — | — | `rate_limited`/`429` | `network_not_ready`/`503` | `internal_error`/`500` | | `CancelJob` | `malformed_request`/`400` | `job_not_found`/`404` | `wrong_phase`/`409` | — | — | `rate_limited`/`429` | — | `internal_error`/`500` | | `OpenPullChallenge` | `malformed_request`/`400` | — | — | — | — | `rate_limited`/`429` | — | `internal_error`/`500` | | `Pull` | `malformed_request`/`400` | — | — | `unauthorized`/`401`, `challenge_expired`/`410` | `scope_exceeded`/`403` | `rate_limited`/`429` | — | `internal_error`/`500` | | `GetRecord` | `malformed_request`/`400` | `not_found`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | `scope_exceeded`/`403` | `rate_limited`/`429` | — | `internal_error`/`500` | | `GetCoinProof` | `malformed_request`/`400` | `not_found`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | `scope_exceeded`/`403` | `rate_limited`/`429` | — | `internal_error`/`500` | -| `GetAccountState` | `malformed_request`/`400` | — | — | `unauthorized`/`401` (missing/invalid bearer **or** grant session), `session_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | -| `SubscribeReceipts` | `malformed_request`/`400` | — | — | `unauthorized`/`401`, `session_expired`/`410` | `scope_exceeded`/`403` | `rate_limited`/`429` | — | `internal_error`/`500` | -| `Publish` | `malformed_request`/`400` | — | — | — | — | `rate_limited`/`429` | — | `internal_error`/`500` | +| `GetAccountState` | `malformed_request`/`400` | — | — | `unauthorized`/`401` (missing/invalid bearer **or** grant session), `session_expired`/`410` | — | `rate_limited`/`429` | `network_not_ready`/`503` | `internal_error`/`500` | +| `SubscribeReceipts` | `malformed_request`/`400` | — | — | `unauthorized`/`401`, `session_expired`/`410` | `scope_exceeded`/`403` | `rate_limited`/`429` | `network_not_ready`/`503` | `internal_error`/`500` | +| `Publish` | `malformed_request`/`400` | — | — | — | — | `rate_limited`/`429` | `network_not_ready`/`503` | `internal_error`/`500` | | `EntrustOperationalBundle` | `malformed_request`/`400` | — | — | `unauthorized`/`401`, `challenge_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | | `RevokeOperationalBundle` | `malformed_request`/`400` | — | — | `unauthorized`/`401`, `challenge_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | -| `AttestBalance` | `malformed_request`/`400` | — | — | `unauthorized`/`401`, `challenge_expired`/`410` | — | `rate_limited`/`429` | `circuit_digest_mismatch`/`503` | `internal_error`/`500` | +| `AttestBalance` | `malformed_request`/`400` | — | — | `unauthorized`/`401`, `challenge_expired`/`410` | — | `rate_limited`/`429` | `network_not_ready`/`503` | `internal_error`/`500` | | `IssueViewGrant` | `malformed_request`/`400` | — | — | `unauthorized`/`401`, `challenge_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | | `GetTokenProvenance` | `malformed_request`/`400` (asset_id width) | `not_found`/`404` | — | — | — | `rate_limited`/`429` | — | `internal_error`/`500` | +| `ListGroups` | `malformed_request`/`400` | `feature_disabled`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | +| `CreateGroup` | `malformed_request`/`400` | `feature_disabled`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | +| `GetGroup` | `malformed_request`/`400` | `not_found`/`404`, `feature_disabled`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | +| `SendGroupMessage` | `malformed_request`/`400` | `not_found`/`404`, `feature_disabled`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | +| `ListGroupMessages` | `malformed_request`/`400` | `not_found`/`404`, `feature_disabled`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | +| `InviteGroupMember` | `malformed_request`/`400` | `not_found`/`404`, `feature_disabled`/`404` | `invitee_not_ready`/`409` | `unauthorized`/`401`, `session_expired`/`410` | `not_group_admin`/`403` | `rate_limited`/`429` | — | `internal_error`/`500` | +| `RemoveGroupMember` | `malformed_request`/`400` | `not_found`/`404`, `feature_disabled`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | `not_group_admin`/`403` | `rate_limited`/`429` | — | `internal_error`/`500` | +| `LeaveGroup` | `malformed_request`/`400` | `not_found`/`404`, `feature_disabled`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | +| `PublishKeyPackage` | `malformed_request`/`400` | `feature_disabled`/`404`, `not_found`/`404` | — | `unauthorized`/`401`, `session_expired`/`410` | — | `rate_limited`/`429` | — | `internal_error`/`500` | (`Publish` policy/crypto rejections remain **successful** RPC responses with `PublishResult.accepted == false` and a closed `reason`, mirroring §7.6 HTTP 200 — they are not gRPC Status failures.) @@ -3610,7 +3752,7 @@ A short, scannable reference for the jargon, notation, and identifier names used - **`completed` (transaction state)** — the on-chain nullifier's signature verifies (§3.2) and its `Pkᵢ` is the **first occurrence** of that key in the accumulator (§3.6) **AND** its inclusion block has ≥ 6 confirmations, at which point it is **final**; the only state in which a receiver MAY credit; a reorg of ≥6 blocks MAY reverse it — an accepted v1 limitation, not a recovery case (§3.9). ([§3.10](#310-transaction-states)) - **conditional NAV** — a transition's chain-derived nullifier-accumulator value `nav` that contains every nullifier it depends on (its prior account state's nullifier and each input/received coin's creating-transition nullifier); exposed only through the hiding `nav_commitment` (the fifth `ProofData` field), carried forward monotonically by `prefix`, and required canonical on a verifier's own scan. Reorg handling is bounded by the 6-confirmation finality directive (§3.9), not by a no-op branch. ([§1.4](#14-identifiers-and-hashes), [§2.1 clause 1](#21-the-compliance-predicate), [§3.9](#39-finality-and-reorg-handling)) - **Cyclic recursion** — one fixed circuit verifies proofs of itself; verifier data is constant, so proof size and verification time are constant. ([§2.2](#22-proof-types)) -- **Data Permanence** — a node never deletes received data: every artefact is stored completely and retained indefinitely and unconditionally (no drop, expiry, pruning, supersession clean-up, or time-bounded retention). Applies to protocol artefacts, idempotency mappings, and stored accumulator log entries (orphaned/non-canonical entries are excluded from the active view, never removed from the store). ([Requirement 12](/requirements#12-data-permanence), [§4.8](#48-durability--the-store-everything-invariant)) +- **Data Permanence** — a node never deletes received data: every artefact is stored completely and retained indefinitely and unconditionally (no drop, expiry, pruning, supersession clean-up, or time-bounded retention), except Marmot MLS application messages when `group_chat` is on **(v2 only — NOT applicable in v1)** ([Group chat](/group-chat)). Applies to protocol artefacts, idempotency mappings, and stored accumulator log entries (orphaned/non-canonical entries are excluded from the active view, never removed from the store). ([Requirement 12](/requirements#12-data-permanence), [§4.8](#48-durability--the-store-everything-invariant)) - **DeliveryEvent** — Nostr delivery: rumor payload `{blob_id, blob_locators, ack_nonce}` (optional `record_kind ∈ {"mint","send","receive"}` for self-delivery — closed JSON string literals, same value set as §7.5 `transition_kind`), NIP-44 encrypted to `IVPK` and NIP-59 gift-wrapped under an ephemeral key; the **outer** kind-1059 event carries **only** the per-coin scan tags `zkdt`/`zkepk` in cleartext. `blob_locators` is the **base64url-no-pad** of `serialize(BlobLocatorSet)` (`holders` only; `blob_id` is context) inside the sealed payload. The `ack_nonce` is a fresh sender-chosen 32-byte value the recipient echoes in the kind-1421 ACK content `{detect_tag, blob_id, ack_nonce, op_sig}`, binding the ACK to this delivery attempt. ([§4.2](#42-bundle-delivery), [§7.3](#73-nostr-event-kinds-normative)) - **`detect_tag`** — `Hc("DetectTag", ss ‖ epk)`, where `ss = ECDH(esk, IVPK) = ECDH(ivk, epk)`; per-coin, all-distinct, recipient-side scan only (one ECDH + one hash per candidate) — no relay filter and no cross-coin linkability. ([§1.3](#13-per-coin-keys-note-encryption--detection), [§4.4](#44-note-discovery)) - **`epk` (ephemeral pubkey)** — `esk·G`, drawn fresh per output coin; the recipient's `K_tx` and `detect_tag` are derived from it. ([§1.3](#13-per-coin-keys-note-encryption--detection)) @@ -3620,6 +3762,7 @@ A short, scannable reference for the jargon, notation, and identifier names used - **Fuzzy message detection (FMD)** — future-version (not in v1) probabilistic relay-side pre-filter; reduces the recipient's download volume, not its linkability (the per-coin scheme already has none). ([§1.3](#13-per-coin-keys-note-encryption--detection), [§4.7](#47-metadata-and-privacy-tradeoffs)) - **`GetTokenProvenance`** — the kernel RPC behind the open REST read `GET /v1/token//provenance`; returns the self-verifying `IssuanceTerms` for an `asset_id` when the node has retained them (Class B); `404 not_found` otherwise. ([§7.5](#75-node-rest-api-normative), [§7.8](#78-kernel-rpc--the-internal-interface-normative)) - **Goldilocks** — the proof field `𝔽` with prime `p = 2^64 − 2^32 + 1`; pinned for Poseidon. ([§1.1](#11-cryptographic-primitives)) +- **`group_chat`** — **v2 feature — NOT applicable in v1**. Advertises the Marmot/MLS group overlay. Independent of NIP-17, of both bridges, and of payment conformance. ([Group chat](/group-chat), [§6.1](#61-components-and-responsibilities)) - **Half-aggregation** — non-interactive compression of many transitions' BIP-340 nullifier signatures into one shared aggregate scalar `s_agg`, retaining each `(Pkⱼ, Rⱼ)`; publisher-side, **off-chain before inscription** ([§3.3](#33-half-aggregation)); only the aggregated result is inscribed (no secret keys, no proof) so `m` nullifiers cost ~64 bytes each. - **`Hc`** — see *Notation*. - **HKDF** — HKDF-SHA-256 (RFC 5869), used for symmetric/derived secrets (`K_tx`, `K_out`, `nav_rand`, ZBE's `kb`); the `HKDF(tag, material)` shorthand's `IKM`/`salt`/`info`/`L` parameter mapping is fixed once, normatively, in [§1.1](#11-cryptographic-primitives). ([§1.1](#11-cryptographic-primitives)) @@ -3636,6 +3779,8 @@ A short, scannable reference for the jargon, notation, and identifier names used - **`m_state`** — the **per-network fixed** protocol-constant message (`"zkCoins/v1/StateUpdate/mainnet"` \| `"…/testnet"` \| `"…/regtest"`) every account transition on that network signs; the transition's specifics are bound into the signature's nonce by sign-to-contract (`H(ProofData)`), not into the message, which keeps the on-chain nullifier at ~64 bytes and lets a scanner verify with no off-chain data using the per-network fixed constant for the network the scanner operates on. Closes on-chain cross-network replay of a raw `(Pk, R, s)`. ([§1.4](#14-identifiers-and-hashes), [§3.2](#32-transition-signing-bip-340--sign-to-contract)) - **`network_id`** — `Hc("Network", network_tag_bytes)`; public input of `C` and `C_balance` (4 Goldilocks field elements); **placement differs by circuit:** in **`C`**, `network_id` lies **after `consumed_pubkey`**; in **`C_balance`** there is **no** `consumed_pubkey` and `network_id` is the **last** public input. Proof-level network binding only — does **not** bind the BIP-340 signature. ([§1.4](#14-identifiers-and-hashes), [§2.5](#25-circuit-dimensioning-normative), [§5.7](#57-balance-attestation-history-private)) - **Mint** — the issuance transition; produces a creator-owned coin under the asset's token standard — `IssuanceTerms_v1` (uncapped) or `IssuanceTerms_v2` (auditable capped supply via `cap_total`, §6.5) (the creator of the asset is its sole minter; anyone can create their own asset, no one can mint someone else's); spends no input coin but is a **state-advancing** transition that consumes its state's one-time key `Pkᵢ` and **publishes its on-chain nullifier `(Pkᵢ, Rᵢ)`**, arbitrated by first-occurrence exactly like a spend — its receiver both re-verifies the mint's recursive proof (an `InitialProof`, or an `AccountUpdateProof` carrying `asset_issuance` for a follow-up mint) and checks that nullifier's `completed` state. ([§2.3.1](#231-mint--issuance), [§3.10](#310-transaction-states), [§6.5](#65-issuance--token-standards)) +- **MLS** — Messaging Layer Security, RFC 9420. The group crypto adopted via Marmot; leaf and HPKE-init secrets are CSPRNG-generated node-held operational state, epoch secrets come from the MLS key schedule, and none are derived from `op` or the seed. ([Group chat](/group-chat)) +- **Marmot** — the adopted group protocol: MLS over Nostr transport binding `marmot.transport.nostr` version 1, pinned at git commit `4a2bc65f8db5`. zkCoins defines only the feature flag, custody, REST, and negative controls; Marmot owns MLS and transport bytes. ([Group chat](/group-chat)) - **`NAV(tip)`** — `(accumulator, tip_block_hash, tip_height)`; the accumulator's value at a stated Bitcoin tip; an authenticated membership (inclusion) answer is meaningful only relative to a `NAV`. Path-B `present: false` is an unauthenticated index answer, not a tip-anchored non-inclusion proof. ([§3.7](#37-the-nullifier-accumulator)) - **`nav_commitment`** / **`nav_opening`** — the fifth `ProofData` field, `Hc("NavCommit", nav_root ‖ nav_rand)`: the **hiding** commitment to a transition's conditional NAV that a proof exposes publicly, so chain observers learn nothing of the account's receive-recency. Opened (`nav_opening = {nav, nav_rand}`) to a coin's recipient (via the `CoinProof` bundle) or a disclosure verifier, who checks `nav` is canonical on their own scan; under the deferred fee mechanism the fee coin's `nav_opening` would be handed to the publisher ([§3.8.1](#381-fee-coin-mechanism-deferred)) — v1 hands it none — and by default it opens the shared `size_final` ordinal (identical for every prover at a given tip), so it reveals no receive-recency. ([§1.4](#14-identifiers-and-hashes), [§2.1](#21-the-compliance-predicate), [§2.3.2](#232-send), [§2.3.3](#233-receive)) - **`nav_rand`** — `HKDF("zkCoins/v1/NavRand", op_secret ‖ u64-be(send_counter))`; the deterministic 256-bit randomness that makes `nav_commitment` hiding; reproducible by any holder of the operational bundle (so a fresh node rebuilds any prior opening) and MUST NOT be derived from `nav`. ([§1.4](#14-identifiers-and-hashes)) @@ -3908,6 +4053,7 @@ The V.4 bytes are authoritative only as produced by the reference implementation | V.11 nullifier-accumulator log vectors (`nflog_empty`, hand-listed smoke set over the pinned sample-leaf sequence for small `n ≤ 9`, **and** the **generated log-boundary suite** for every `k = 0…63` as symbolic subtree-root fixtures) | MUST produce | MUST reproduce | **hand-listed:** **byte-equal** (reference-implementation bytes); **boundary suite:** Accept\|Reject on split/peak-bagging with given O(log n) subtree roots (`` Poseidon nodes, harness-generated) — **no** Θ(n) materialisation. Structure / sizes / Accept\|Reject normative. Feeds the D-05 release gate ([§1.7.8](#178-reference-instantiation-status-final-for-v1)) | | V.10 note-encryption fixture (keys + `NIP44Binary` envelope preimages) | MUST reproduce | MUST reproduce | **byte-equal** on keys/`out_plain`; NIP-44 AEAD via NIP-44 vectors; envelope negatives reject | | V.12 profile + NIP-17 wire vectors (incl. the name-loss, name-consent, and publisher-profile controls) | MUST produce/accept every positive fixture and reject every negative | MUST reproduce the profile checks and the wire construction within its scope | three-check payment binding, `name_sig` on a name-resolved profile, independent-seal construction; the live client matrix is an `app`-layer release check, not a wire vector | +| V.13 group-chat / Marmot overlay | MUST produce **(v2 only)** | MUST NOT implement MLS; calls `/v2/groups*` **(v2 only)** | emit/accept the pinned Marmot wire and reject G-01–G-10; White Noise interop is an `app`-layer release check — **v2 only; not a v1 conformance target** | | V.9 negative controls | MUST reject every case | MUST reject every case within its scope (signing/encoding cases) | each case rejects with the named reason | ### V.8 Signing & half-aggregation fixture (synthetic, fully pinned) @@ -3990,7 +4136,7 @@ A conforming implementation **MUST** reject every case below with the named outc | N-07 | Truncate any fixed-width `CoinProof` field by one byte / leave one trailing byte | bundle malformed, rejected ([§7.1](#71-serialization-conventions-normative)) | | N-08 | Verify a proof built for network tag `zkCoins/v1/testnet` (with testnet `network_id` and testnet digests) against the `zkCoins/v1/mainnet` pinned digests | rejected — `circuit_digest` mismatch ([§1.7.9](#179-proof-system-parameters-normative), [§2.5](#25-circuit-dimensioning-normative)) | | N-09 | Force a ≤5-block regtest reorg across a `pending` nullifier | canonical replay converges: the accumulator value `(size, mth)` and `nav_root = Hc("NfLog/Root", size ‖ mth)` equal a fresh full rescan's ([§3.9](#39-finality-and-reorg-handling)) | -| N-10 | Force a ≥6-block regtest reorg displacing a `completed` nullifier | the node **detects** the displacement, its `/health/ready` stops reporting ready, and it does not credit against the broken state (the suite asserts detection and fail-stop, not recovery — [§3.9](#39-finality-and-reorg-handling)) | +| N-10 | Force a ≥6-block regtest reorg displacing a `completed` nullifier | the node **detects** the displacement, its `GET /v1/{network}/health/ready` stops reporting ready (single-network **MAY**: `GET /health/ready`), and it does not credit against the broken state (the suite asserts detection and fail-stop, not recovery — [§3.9](#39-finality-and-reorg-handling)) | | N-11 | A payload with `version = 0x04` or `format = 0x02` | malformed header — zero valid nullifiers ([§3.5](#35-inscription-format)) | | N-12 | Substitute a different (valid) proof's `H(ProofData)` into a CommVerify opening | `CommVerify` returns false (executable against V.8) | | N-13 | A transition whose per-asset outputs exceed inputs+mint only modulo 2¹²⁸ (conservation wrap) | proof unsatisfiable — wide-integer comparison ([§2.1 clause 3](#21-the-compliance-predicate)); executable at runbook step ≥ 5 | @@ -4195,3 +4341,35 @@ A failure **blocks the release when it is attributable to zkCoins** — that is, **Acceptance (protocol wire vectors — mandatory):** accept all three positive NIP-05 variants, the **name-loss survivability** fixture, the **name-consent** fixture, its **framing vector** with all four mutations rejected and the normalization control accepted, the **producer-side name obligation**, and the **publisher-profile** fixture; reject P-01–P-07 with the named scope; block the silent change or use of a pinned payment identity in P-08–P-09; treat P-10 as first use with a warning; complete payment in P-11; reject M-01–M-15 with the named scope and handle M-16, M-16b, and M-17 exactly as stated; pass the independent-seal and distinct-wrapper-key assertions. **Acceptance (application interop — release check):** every live test passes in both directions with Primal, Amethyst, Damus, and 0xchat, or each failure is recorded and shown to reproduce against a non-zkCoins counterpart. A failure attributable to zkCoins blocks the release; an external client defect does not. + +### V.13 Group-chat / Marmot overlay {#v13-group-chat--marmot-overlay} + +**v2 feature — NOT a v1 conformance target.** Same status as V.2-passkey. This group tests the Marmot/MLS overlay ([Group chat](/group-chat)). It is **normative for a later protocol version (v2)** and documented here for continuity. A **v1** release **MUST NOT** advertise `group_chat` and **skips V.13**. V.13 does not gate V.12, A-to-Z, or payment conformance in v1. When protocol v2 activates it, the group applies only when the layer advertises `group_chat`. + +**Wire (accept).** A conforming producer with the feature on **MUST**: + +- emit and accept kind `30443` KeyPackages with the Marmot tag set (`d`, `mls_protocol_version=1.0`, `i`, `mls_ciphersuite`, `mls_extensions`, `mls_proposals`, `app_components` including `0x8009`), standard-base64 `mls_key_package` content, authored by `op_pubkey`; +- emit and accept kind `1059` → `13` → unsigned kind `444` Welcomes with exactly one `e` and exactly one `relays` tag, standard-base64 `mls_welcome`, no rumor `sig`; +- emit and accept kind `445` with exactly one lowercase-hex 32-byte `h`, a fresh unused ephemeral `pubkey` that is not `op`, and standard-base64(`nonce_12 || ChaCha20-Poly1305(group_event_key, nonce, mls_message_bytes, aad="")`) where `group_event_key = MLS-Exporter("marmot", "group-event", 32)`; +- emit a valid `op`-signed kind `10002` NIP-65 list whose write-capable set (`r` marker `write` or omitted) is where KeyPackages are published; +- publish KeyPackages to the account's NIP-65 write-capable set, Welcomes to the invitee's kind-10050 set, and group messages to the routing relay list under Marmot's first-attempt obligation; +- deduplicate on the recovered MLS message id, never on the Nostr event id. + +**Negative controls (each MUST reject with no conversation mutation).** + +| # | Case | Expected | +|---|---|---| +| G-01 | extra recipient `p` on a kind-14 rumor used as a “group” | reject as outside the one-to-one NIP-17 profile; no Marmot group is created | +| G-02 | treating NIP-29 / kind 9 / kind 42 as E2E group chat | reject; those kinds are not this overlay | +| G-03 | deriving MLS leaf, HPKE-init, epoch, exporter, or `group_event_key` material from `op` / seed / `op_secret` / `nk` / `ivk` / `ovk` / SPEND / a §1 HKDF tag | reject the construction; leaf and HPKE-init MUST be CSPRNG; epoch / exporter / `group_event_key` MUST be MLS-derived | +| G-04 | kind 445 authored by `op_pubkey` or with a reused ephemeral key | reject the event | +| G-05 | kind 445 missing, duplicate, or `h` not 32-byte lowercase hex | reject the event | +| G-06 | Welcome not addressed to the local `op_pubkey` | reject before join | +| G-07 | signed kind-444 rumor | reject as a non-Marmot rumor | +| G-08 | silent fallback NIP-17 ↔ Marmot ↔ SMTP | abort; conversations stay separate | +| G-09 | advertising API `group_chat` without `wallet`, or calling `/v2/groups*` while the kernel `group_chat` part is off | do not advertise `group_chat` without `wallet`; after the session checks, every `/v2/groups*` request answers `404 feature_disabled` | +| G-10 | publishing a Welcome to a guessed relay when kind 10050 is absent | abort before every network write; HTTP `409` `{ "error": "invitee_not_ready" }` | + +**Live interoperability matrix (evaluated at the `app`/SDK layer, only when `group_chat` is on).** Bidirectional application-text exchange against a current White Noise / Marmot client that speaks `marmot.transport.nostr` v1 at git commit `4a2bc65f8db5`: create, invite via KeyPackage, send, receive, remove a member, leave. A failure attributable to zkCoins is a V.13 fail, not a V.12 fail. + +**Acceptance:** when the feature is on, emit/accept the wire shapes above, reject G-01–G-10, and pass the White Noise matrix or record each failure as an external defect that reproduces against a non-zkCoins Marmot counterpart. When the feature is off, skip this group. diff --git a/sidebars.js b/sidebars.js index 55b10b6..d75e078 100644 --- a/sidebars.js +++ b/sidebars.js @@ -10,12 +10,14 @@ const sidebars = { 'protocol', 'lightning-bridge', 'mail-bridge', + 'group-chat', 'comparisons', 'risks', 'assurance', 'paper-conformance-analysis', 'paper-conformance-remediation', 'proposals-v1.3-bootstrap-seeds', + 'proposals-kernel-v1-wire-completeness', ], };