From 2dad5568816d9e4db5613b2d586d237ccf5e47b5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 18:29:54 +0200 Subject: [PATCH 01/19] chore: open release branch for mainnet-hardening bundle Empty commit to seed the release branch. The actual changes land via: - #114 fix(ci): set ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER on PRD api-e2e - #115 fix(network-config): require ESPLORA_URL + ESPLORA_WS_URL on Mainnet The branch bundles the two PRs so they reach develop as a single "mainnet hardening" release rather than two independent develop pushes. The maintainer squash-merges this PR after both #114 and #115 land here. From feacd31ba49482ec78a9d7780a3c4ca6ee3d91fc Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 18:31:03 +0200 Subject: [PATCH 02/19] fix(ci): set ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER on PRD api-e2e (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRD image ships the same MVP-only binary as DEV per Dockerfile policy, but deploy-prd.yaml's api-e2e step was missing the escape-hatch env var that lets feature-gated tests (address-list, lnurl) skip cleanly instead of panicking the CI canary. PR #18 (develop→main auto-release) brought the feature-trimmed-server check from PR #105 to main, and the next PRD deploy (run 26441824314) failed with 4 panics on tests gated by features the MVP build never enables. Mirror deploy-dev.yaml's env block on the PRD api-e2e step. --- .github/workflows/deploy-prd.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/deploy-prd.yaml b/.github/workflows/deploy-prd.yaml index 8af19ffe..0b67afcd 100644 --- a/.github/workflows/deploy-prd.yaml +++ b/.github/workflows/deploy-prd.yaml @@ -135,6 +135,13 @@ jobs: sccache --show-stats - name: Run API E2E suite against PRD (skip roundtrips) + env: + # PRD image is MVP-only by policy (see Dockerfile FEATURES + # arg — "both DEV and PRD images ship the MVP-only feature + # set so the two environments run the identical binary"). + # The gated address-list/lnurl tests skip cleanly instead of + # panicking the CI canary. Mirrors deploy-dev.yaml. + ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER: "true" run: cargo test -p node --release --all-features --test api_remote -- --test-threads=1 --nocapture --skip _roundtrip_ - name: sccache stats (post-build) From 7805692ecc538b4ae28ed0cf8a2e32efa20e2963 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 18:31:32 +0200 Subject: [PATCH 03/19] fix(network-config): require ESPLORA_URL + ESPLORA_WS_URL on Mainnet (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NETWORK_CONFIG` silently fell back to the Mutinynet defaults (`https://mutinynet.com/api`, `wss://mutinynet.com/api/v1/ws`) when the env var was missing, regardless of `IS_MAINNET`. On DEV that matches the chain. On Mainnet it's a silent footgun: - An HTTP-only mismatch panics quickly on the first publisher round-trip and the operator sees the breakage immediately. - The new event-driven scanner (#84) instead subscribes to Mutinynet block events and tries to fetch them from the Mainnet HTTP Esplora. Every `get_block_txids` returns 404 and `scanner_runtime` enters a 5 s HTTP-retry loop that never updates `processed_blocks`: the service stays up, `/health/ready` reports green, no chain ingestion happens, no on-chain mint or send commit is ever picked up. The binary never self-heals after restart because no env-derived state has changed. `ESPLORA_URL` and `ESPLORA_WS_URL` are now both **required env vars when `IS_MAINNET=true`** (panic with diagnostic message, mirroring the existing `PUBLISHER_KEY` / `USERNAME_DOMAIN` / `DATABASE_URL` idiom in the same file). Empty / whitespace-only values are treated as unset so a `ESPLORA_URL=` line in a compose file panics with the same message instead of leaving `EsploraConfig.url = ""`. The `IS_MAINNET=false` (DEV / Mutinynet) path is unchanged — both URLs keep their Mutinynet defaults, the pre-push hook and the M3 Ultra coverage gate are unaffected. Implementation: pulled the env-resolution out of the `lazy_static!` block into a pure `build_network_config_from_env(env: F)` so the panic rules are unit-testable without `std::env::set_var` (which would poison the `NETWORK_CONFIG` cell across tests in the same binary). Seven new `#[test]`s cover the headline shapes plus the empty-string and whitespace-only rejection paths. The guard is enforced at the `NETWORK_CONFIG` access path only. `scanner_ws::ScannerWsConfig::from_env` and `publisher.rs` still read `ESPLORA_WS_URL` independently with the Mutinynet fallback — in the main binary the panic in this builder fires first (main.rs dereferences `NETWORK_CONFIG` during bootstrap, before any scanner or publisher env read), so the structural bypass is unreachable today. Closing that bypass by having those sites consume `NETWORK_CONFIG.ws_url` directly is tracked as a follow-up. --- node/src/lib.rs | 118 ++++++++++++++++++++++++++----- node/src/main_tests.rs | 157 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 247 insertions(+), 28 deletions(-) diff --git a/node/src/lib.rs b/node/src/lib.rs index a3f43208..e91e02c3 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -44,24 +44,108 @@ use sqlx::PgPool; use std::str::FromStr; use zkcoins_program::hash::HashDigest; -lazy_static! { - pub static ref NETWORK_CONFIG: EsploraConfig = { - let url = std::env::var("ESPLORA_URL") - .unwrap_or_else(|_| "https://mutinynet.com/api".to_string()); - let is_mainnet = std::env::var("IS_MAINNET") - .map(|v| v == "true") - .unwrap_or(false); - let network_name = std::env::var("NETWORK_NAME") - .unwrap_or_else(|_| if is_mainnet { "Mainnet".to_string() } else { "Mutinynet".to_string() }); - let ws_url = std::env::var("ESPLORA_WS_URL").ok(); - println!( - "Network config: {} ({}) ws={}", - network_name, - url, - ws_url.as_deref().unwrap_or(crate::scanner_ws::DEFAULT_ESPLORA_WS_URL) - ); - EsploraConfig { url, is_mainnet, network_name, ws_url, track_tx_timeout: None } +/// Pure builder for `NETWORK_CONFIG`. Extracted so the env-resolution +/// logic — in particular the panic-on-missing rules below — is +/// unit-testable without touching the process-wide environment or the +/// `lazy_static` cell (whose state would leak across tests in the same +/// binary). +/// +/// ## Mainnet vs Mutinynet defaults +/// +/// `ESPLORA_URL` and `ESPLORA_WS_URL` have Mutinynet defaults +/// (`https://mutinynet.com/api`, `wss://mutinynet.com/api/v1/ws`) +/// throughout the codebase. They are convenient for DEV (Mutinynet +/// the chain) and harmless for unit/integration tests. But on Mainnet +/// they are silent footguns: an `IS_MAINNET=true` deployment that +/// forgets to set either env publishes Mutinynet block events into +/// the scanner and / or fetches the wrong chain over REST. The +/// failure mode is asymmetric — an HTTP-only mismatch panics quickly +/// on the first publisher round-trip, but the event-driven scanner +/// (#84) sits in a 5 s HTTP-retry loop with no forward progress and +/// a green `/health/ready`. +/// +/// To remove the footgun, both URLs are **required env vars when +/// `IS_MAINNET=true`** — the panic mirrors the existing pattern for +/// `PUBLISHER_KEY`, `USERNAME_DOMAIN`, and `DATABASE_URL`. Empty- +/// string values are treated as unset on the Mainnet path so a +/// misconfigured compose file (`ESPLORA_URL=`) panics with the same +/// diagnostic instead of silently producing `EsploraConfig.url = ""`. +/// When `IS_MAINNET` is unset or `false`, the Mutinynet defaults +/// continue to apply — DEV, the pre-push hook, and the M3 Ultra +/// coverage gate are all unaffected. +/// +/// ## Scope of the guard +/// +/// Only the `NETWORK_CONFIG` access path is hardened here. +/// `scanner_ws::ScannerWsConfig::from_env` and `publisher.rs` still +/// call `std::env::var("ESPLORA_WS_URL")` independently with a +/// Mutinynet fallback. In the production binary the panic in this +/// builder fires before any of those reads — `main.rs` dereferences +/// `NETWORK_CONFIG` during bootstrap — so the structural bypass is +/// unreachable today. A follow-up that has those sites consume +/// `NETWORK_CONFIG.ws_url` (or an explicit `&EsploraConfig`) directly +/// would close the bypass for future entry points and is tracked as +/// a separate refactor. +pub fn build_network_config_from_env(env: F) -> EsploraConfig +where + F: Fn(&str) -> Option, +{ + // Treat empty strings as "unset" on the Mainnet path. Without + // this, `ESPLORA_URL=` in a compose file bypasses the `expect` + // below and leaves `EsploraConfig.url = ""` — the same class of + // silent misconfiguration the panic is designed to surface. + let env_or_unset = |k: &str| env(k).filter(|v| !v.trim().is_empty()); + let is_mainnet = env_or_unset("IS_MAINNET").as_deref() == Some("true"); + let url = if is_mainnet { + env_or_unset("ESPLORA_URL").expect( + "IS_MAINNET=true requires ESPLORA_URL to be set to a non-empty value — \ + the Mutinynet default is unsafe on Mainnet. Set ESPLORA_URL \ + to a Mainnet HTTP Esplora endpoint (e.g. http://electrs-mainnet:3000 \ + on the DFX Mainnet stack, or https://mempool.space/api)", + ) + } else { + env_or_unset("ESPLORA_URL").unwrap_or_else(|| "https://mutinynet.com/api".to_string()) + }; + let ws_url = if is_mainnet { + Some(env_or_unset("ESPLORA_WS_URL").expect( + "IS_MAINNET=true requires ESPLORA_WS_URL to be set to a non-empty value — \ + the Mutinynet default (wss://mutinynet.com/api/v1/ws) is unsafe on \ + Mainnet: the event-driven scanner subscribes to Mutinynet block \ + events and 404s against the Mainnet HTTP Esplora in a 5 s retry \ + loop with no forward progress (zk-coins/node #84). Set \ + ESPLORA_WS_URL to a Mainnet mempool.space-compatible WebSocket \ + (e.g. wss://mempool.space/api/v1/ws)", + )) + } else { + env_or_unset("ESPLORA_WS_URL") }; + let network_name = env_or_unset("NETWORK_NAME").unwrap_or_else(|| { + if is_mainnet { + "Mainnet".to_string() + } else { + "Mutinynet".to_string() + } + }); + println!( + "Network config: {} ({}) ws={}", + network_name, + url, + ws_url + .as_deref() + .unwrap_or(crate::scanner_ws::DEFAULT_ESPLORA_WS_URL) + ); + EsploraConfig { + url, + is_mainnet, + network_name, + ws_url, + track_tx_timeout: None, + } +} + +lazy_static! { + pub static ref NETWORK_CONFIG: EsploraConfig = + build_network_config_from_env(|k| std::env::var(k).ok()); /// Domain used by the client to render `@`. /// Distinct from `network_name` because the same Bitcoin network diff --git a/node/src/main_tests.rs b/node/src/main_tests.rs index 8f2e003f..490b358a 100644 --- a/node/src/main_tests.rs +++ b/node/src/main_tests.rs @@ -1,19 +1,154 @@ -// Bootstrap-level tests for `main.rs`. -// -// Today the only thing here is regression coverage for the -// `block_in_place(block_on(...))` bridge used inside the scanner's -// synchronous `InscriptionCallback`. Without `block_in_place`, the -// naive `Handle::current().block_on(persist_state_tx(…))` form panics -// at runtime on the multi_thread tokio runtime (the default for -// `#[tokio::main]`) — and "runtime" here means "the first time the -// scanner sees a real inscription on Mutinynet". CI did not catch the -// original form because no integration test ever drove the sync -// callback through a real multi_thread worker; this test does. +// Bootstrap-level tests for `main.rs` and the lib-root helpers it +// invokes (`build_network_config_from_env`, the +// `persist_state_from_sync_context` bridge). use super::*; use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; use testcontainers_modules::postgres::Postgres; +// --- build_network_config_from_env ------------------------------- +// +// These tests cover the panic-on-missing rules for the Mainnet path. +// They use a fake `env` closure rather than `std::env::set_var` so +// the panic side-effect cannot poison the `NETWORK_CONFIG` +// lazy_static cell (shared across tests in this binary) and so the +// tests do not race other test threads via the process-wide +// environment. + +/// Build a closure-shaped env from a slice so the tests read like a +/// table. Returns the first matching value or `None`. +fn fake_env(entries: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |k| { + entries.iter().find_map(|(name, value)| { + if *name == k { + Some((*value).to_string()) + } else { + None + } + }) + } +} + +#[test] +fn build_network_config_defaults_to_mutinynet_when_is_mainnet_unset() { + let cfg = build_network_config_from_env(fake_env(&[])); + assert!(!cfg.is_mainnet); + assert_eq!(cfg.url, "https://mutinynet.com/api"); + assert_eq!(cfg.network_name, "Mutinynet"); + assert!(cfg.ws_url.is_none()); +} + +#[test] +fn build_network_config_defaults_to_mutinynet_when_is_mainnet_is_not_true() { + // Any value other than the literal string "true" is treated as + // "not mainnet" — same semantics as the legacy `.map(|v| v == "true")` + // pattern. Guards against accidental "TRUE" / "1" / "yes" thinking + // it switches the network. + let cfg = build_network_config_from_env(fake_env(&[("IS_MAINNET", "1")])); + assert!(!cfg.is_mainnet); + assert_eq!(cfg.url, "https://mutinynet.com/api"); + assert!(cfg.ws_url.is_none()); +} + +#[test] +fn build_network_config_respects_explicit_urls_on_mutinynet() { + let cfg = build_network_config_from_env(fake_env(&[ + ("ESPLORA_URL", "http://electrs-mutinynet:3000"), + ("ESPLORA_WS_URL", "wss://example.test/ws"), + ("NETWORK_NAME", "Custom"), + ])); + assert!(!cfg.is_mainnet); + assert_eq!(cfg.url, "http://electrs-mutinynet:3000"); + assert_eq!(cfg.ws_url.as_deref(), Some("wss://example.test/ws")); + assert_eq!(cfg.network_name, "Custom"); +} + +#[test] +fn build_network_config_full_mainnet() { + let cfg = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", "http://electrs-mainnet:3000"), + ("ESPLORA_WS_URL", "wss://mempool.space/api/v1/ws"), + ])); + assert!(cfg.is_mainnet); + assert_eq!(cfg.url, "http://electrs-mainnet:3000"); + assert_eq!(cfg.ws_url.as_deref(), Some("wss://mempool.space/api/v1/ws")); + assert_eq!(cfg.network_name, "Mainnet"); +} + +#[test] +fn build_network_config_mainnet_with_explicit_network_name() { + // Mainnet path with `NETWORK_NAME` set: the override must win + // over the `if is_mainnet { "Mainnet" } else { "Mutinynet" }` + // default branch. Documents that operators can rename the chain + // label (e.g. "Mainnet-Canary") without changing IS_MAINNET. + let cfg = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", "http://electrs-mainnet:3000"), + ("ESPLORA_WS_URL", "wss://mempool.space/api/v1/ws"), + ("NETWORK_NAME", "Mainnet-Canary"), + ])); + assert!(cfg.is_mainnet); + assert_eq!(cfg.network_name, "Mainnet-Canary"); +} + +#[test] +#[should_panic(expected = "IS_MAINNET=true requires ESPLORA_URL")] +fn build_network_config_panics_on_mainnet_missing_esplora_url() { + let _ = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_WS_URL", "wss://mempool.space/api/v1/ws"), + ])); +} + +#[test] +#[should_panic(expected = "IS_MAINNET=true requires ESPLORA_URL")] +fn build_network_config_panics_on_mainnet_empty_esplora_url() { + // `ESPLORA_URL=` in a compose file resolves to `Some("")`. Without + // the empty-string filter in `env_or_unset`, the `expect` would + // be bypassed and `EsploraConfig.url` would be left as `""` — + // exactly the silent-misconfiguration class the panic is meant + // to catch. + let _ = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", ""), + ("ESPLORA_WS_URL", "wss://mempool.space/api/v1/ws"), + ])); +} + +#[test] +#[should_panic(expected = "IS_MAINNET=true requires ESPLORA_WS_URL")] +fn build_network_config_panics_on_mainnet_missing_esplora_ws_url() { + let _ = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", "http://electrs-mainnet:3000"), + ])); +} + +#[test] +#[should_panic(expected = "IS_MAINNET=true requires ESPLORA_WS_URL")] +fn build_network_config_panics_on_mainnet_whitespace_esplora_ws_url() { + // Whitespace-only values are also rejected — same misconfiguration + // class as the empty string, just easier to miss in a diff. + let _ = build_network_config_from_env(fake_env(&[ + ("IS_MAINNET", "true"), + ("ESPLORA_URL", "http://electrs-mainnet:3000"), + ("ESPLORA_WS_URL", " "), + ])); +} + +// --- persist_state_from_sync_context ----------------------------- +// +// Regression coverage for the `block_in_place(block_on(...))` bridge +// used inside the scanner's synchronous `InscriptionCallback`. +// Without `block_in_place`, the naive +// `Handle::current().block_on(persist_state_tx(…))` form panics at +// runtime on the multi_thread tokio runtime (the default for +// `#[tokio::main]`) — and "runtime" here means "the first time the +// scanner sees a real inscription on Mutinynet". CI did not catch +// the original form because no integration test ever drove the sync +// callback through a real multi_thread worker; this test does. + /// Spin up a fresh `postgres:17` container, run all migrations, and /// return the live pool. Mirrors `db_tests::setup_pool` but lives in /// this file so the `main.rs` test module stays self-contained. From a7231a1d1ea2647c700738853f8fbd13b6d6f7dc Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 22:31:44 +0200 Subject: [PATCH 04/19] docs: replace "server" with "node"/"API" in root design docs Aligns the prose, ASCII trees, and anchor links across CONTRIBUTING, README, ROADMAP, SPEC, MIGRATION_RESEARCH, BRIDGE_MVP, BITVM_BRIDGE, MULTI_ASSET, LIGHTNING_ATOMIC_SWAP, and ARKADE_INTEGRATION with the post-rename module names (node/src/router.rs, the zkCoins node, etc.). External components (Bitcoin Core, electrs/Esplora, Postgres) keep their proper names; bitcoind's server=1 flag and Docker volume zkcoins_node-data are corrected accordingly. --- ARKADE_INTEGRATION.md | 20 +++++++------- BITVM_BRIDGE.md | 16 +++++------ BRIDGE_MVP.md | 32 ++++++++++----------- CONTRIBUTING.md | 60 ++++++++++++++++++++-------------------- LIGHTNING_ATOMIC_SWAP.md | 40 +++++++++++++-------------- MIGRATION_RESEARCH.md | 28 +++++++++---------- MULTI_ASSET.md | 44 ++++++++++++++--------------- README.md | 40 +++++++++++++-------------- ROADMAP.md | 42 ++++++++++++++-------------- SPEC.md | 8 +++--- 10 files changed, 165 insertions(+), 165 deletions(-) diff --git a/ARKADE_INTEGRATION.md b/ARKADE_INTEGRATION.md index d41207db..9ab96a50 100644 --- a/ARKADE_INTEGRATION.md +++ b/ARKADE_INTEGRATION.md @@ -136,7 +136,7 @@ any of them is a design-level rethink, not a tweak. | **A2** | **No protocol changes to zkCoins or Arkade for A1.** The atomic-swap construction uses primitives both papers already specify: Shielded CSV §5.1 (shared accounts), §A.1.1 (time-locked nullifiers), §A.1.2 (atomic swap); Arkade Script HTLC template (`arkade-os/compiler`, `docs.arkadeos.com/learn/smart-contracts/hash-time-locked-contract`). | No 12th divergence to track in [`SPEC.md`](./SPEC.md) §15. No deviation from the Ark whitepaper. The integration adds wiring, not protocol changes. | | **A3** | **Arkade operator and zkCoins federation remain independent trust domains.** A user holding a VTXO trusts the Arkade operator's rationality (Ark §5 Table 1). A user holding a zkCoins coin pegged to BTC trusts the zkCoins bridge (Phase 1 federation or Phase 2 BitVM2 setup). The two assumptions do not collapse into one; an atomic-swap counterparty may simultaneously occupy both roles, but the trust analyses stay separate. | Operating both an Arkade `arkd` instance and a zkCoins bridge node in the same datacentre is permitted; the security argument tracks each role independently. §8 is the canonical reference for which assumption applies where. | | **A4** | **No confidential-VTXO work in the integration roadmap.** Bringing ZK privacy to Arkade VTXOs (§6.5) is genuine open research — Pedersen commitments + range proofs + redesigned forfeit mechanism + a PCD-style ZK validity proof per Arkade batch. Estimated 1–2 year paper-stage work; no existing protocol or implementation. | This document records confidential VTXOs as a research direction worth tracking but explicitly out-of-scope for any near-term zkCoins effort. If Arkade ships such a feature upstream, this section becomes a re-evaluation gate. | -| **A5** | **Pipeline use (§6.3) is layered on top of A1, not a separate primitive.** "BTC → Arkade → zkCoins → Arkade → BTC" decomposes into: Arkade boarding (Ark §4.5), an HTLC swap into zkCoins (A1), zkCoins-internal transfers, an HTLC swap back out, Arkade exit. Each step is independently specified and the pipeline composes them. | No new design work for the pipeline as long as A1 lands. The wallet-side UX of routing a user through the pipeline is `zk-coins/app` work, not a server-side primitive. | +| **A5** | **Pipeline use (§6.3) is layered on top of A1, not a separate primitive.** "BTC → Arkade → zkCoins → Arkade → BTC" decomposes into: Arkade boarding (Ark §4.5), an HTLC swap into zkCoins (A1), zkCoins-internal transfers, an HTLC swap back out, Arkade exit. Each step is independently specified and the pipeline composes them. | No new design work for the pipeline as long as A1 lands. The wallet-side UX of routing a user through the pipeline is `zk-coins/app` work, not a node-side primitive. | | **A6** | **Cross-asset DEX (§6.6) is a v2 follow-up to A1.** A swap between an Arkade Asset (Arkade Labs' native-asset proposal) and a zkCoins asset is structurally identical to A1 with two field substitutions on each side. It does not require new crypto, but it does require the zkCoins multi-asset shared-account semantics from [`MULTI_ASSET.md`](./MULTI_ASSET.md) to be live, and Arkade Assets to be in production beyond beta. | Tracked as a v2 milestone; not in the initial A1 implementation scope. The first integration ships before chasing this. | These mirror the lockedness pattern of [`MULTI_ASSET.md`](./MULTI_ASSET.md) §2 @@ -267,7 +267,7 @@ maturity. ### 6.1 Layer 0 — independent systems A user holds an Arkade wallet pointing at some Arkade instance and a -zkCoins wallet pointing at a zkCoins server. The wallets do not +zkCoins wallet pointing at a zkCoins node. The wallets do not interoperate. The user manually converts between BTC and zkCoins via the bridge ([`BRIDGE_MVP.md`](./BRIDGE_MVP.md) or [`BITVM_BRIDGE.md`](./BITVM_BRIDGE.md)) and between BTC and Arkade VTXOs @@ -386,7 +386,7 @@ the initial boarding): (no custody handoff possible without preimage reveal), bounded by `T_e` on the Arkade side and the publisher's nullifier-publication cadence on the zkCoins side. -- zkCoins-internal transfers: per [`SPEC.md`](./SPEC.md) — server-side +- zkCoins-internal transfers: per [`SPEC.md`](./SPEC.md) — node-side compute correctness + Schnorr signature security. §8 has the full trust-stacking analysis. @@ -539,12 +539,12 @@ flow, failure modes, trust argument. ### 7.1 Parties and pre-conditions - **User (Alice):** Arkade wallet pointing at some Arkade instance, - zkCoins wallet pointing at a zkCoins server, an existing zkCoins + zkCoins wallet pointing at a zkCoins node, an existing zkCoins account. - **Counterparty (Bob, "swap provider"):** Arkade wallet with VTXO - inventory, zkCoins server with sufficient inventory in some operator + inventory, zkCoins node with sufficient inventory in some operator account. May be the same operator that runs the Arkade instance and - the zkCoins server, or a third party; the protocol does not require + the zkCoins node, or a third party; the protocol does not require it. - **Pre-agreed parameters:** swap amount `A`, provider fee `F`, the on-Arkade HTLC timeout `T_htlc`, the zkCoins-side recovery timeout @@ -785,7 +785,7 @@ reasoning about real-world security. | Arkade operator (rational) | Operator follows protocol | Operator loses their own funds, not users'; users still exit (Ark §5 Table 1) | | Arkade operator (malicious) | Operator deviates | NL, FL still hold; NS, AS, FS violations cost the operator, not users | | Arkade MuSig2 covenant emulation | 1-of-n VTXO holders + operator follow signing protocol | VTXT well-formed (Ark §3.2, §4 Remark 4.5) | -| zkCoins server-side compute | Server runs the published Plonky2 circuit honestly | Per [`CONTRIBUTING.md`](./CONTRIBUTING.md) invariant 1 + invariant 2; closed test environment today, in-circuit verification long-term | +| zkCoins node-side compute | Node runs the published Plonky2 circuit honestly | Per [`CONTRIBUTING.md`](./CONTRIBUTING.md) invariant 1 + invariant 2; closed test environment today, in-circuit verification long-term | | zkCoins Schnorr signatures | BIP-340 / secp256k1 secure | Standard Bitcoin cryptographic assumption | | zkCoins publisher liveness | Some publisher willing to inscribe | Permissionless — alternative publishers can take the nullifier | | zkCoins bridge Phase 1 (federation) | M-of-N federation honesty ([`BRIDGE_MVP.md`](./BRIDGE_MVP.md)) | M+ colluders can steal BTC reserves; zkCoins-side internal transfers unaffected | @@ -800,7 +800,7 @@ The HTLC atomic swap of §7 requires: fallback if violated). - Bitcoin L1 (for confirmation of the inscriptions and any unilateral Arkade exit). -- zkCoins server-side compute (so the publisher accepts and processes +- zkCoins node-side compute (so the publisher accepts and processes the nullifier). - BIP-340 Schnorr security (for both sides' signatures). @@ -817,7 +817,7 @@ The pipeline composes: - Arkade onboarding → Arkade rational operator + Bitcoin L1 - §7 HTLC swap into zkCoins → as in §8.2 -- zkCoins-internal transfers → zkCoins server-side compute + Schnorr +- zkCoins-internal transfers → zkCoins node-side compute + Schnorr - §7 HTLC swap out of zkCoins → as in §8.2 - Arkade exit → Arkade rational operator (cooperative) or pure Bitcoin L1 (unilateral) @@ -971,7 +971,7 @@ between systems. successfully on the other side? Auto-nullify the recovery to free the shared account? - **Recommendation:** track as `zk-coins/app` wallet UX issue once - A1 lands; not a server-side concern. + A1 lands; not a node-side concern. ### 10.6 Multi-asset semantics in A1 (vs. A6) diff --git a/BITVM_BRIDGE.md b/BITVM_BRIDGE.md index 88555feb..b1cc2288 100644 --- a/BITVM_BRIDGE.md +++ b/BITVM_BRIDGE.md @@ -396,13 +396,13 @@ Step 4. User (or their wallet, or any helper service) generates a Bitcoin Light Client Proof showing MovetoVault is in the canonical chain at depth ≥ 6. -Step 5. User submits to a zkCoins server an IssuanceProof request: +Step 5. User submits to a zkCoins node an IssuanceProof request: - Their account state (initial, balance = 0) - The Bitcoin LCP for MovetoVault - The peg-in UTXO outpoint - The non-inclusion proof against peg_in_consumed_smt -Step 6. zkCoins server (or the user's own prover, in a more +Step 6. zkCoins node (or the user's own prover, in a more decentralised future) generates the IssuanceProof: - Verifies the Bitcoin LCP - Verifies the deposit amount equals the requested mint @@ -436,13 +436,13 @@ needed. | Vault sweeps multiple deposits without proper mint authorisation | Pre-signing prevents this (vault can only spend via pre-signed paths) | | User's LCP is forged or stale | Circuit re-verifies LCP from headers; forgery requires breaking PoW | | Bitcoin reorg removes MovetoVault | LCP becomes invalid; user retries after deeper confirmation | -| zkCoins server malicious — refuses to generate IssuanceProof | User goes to another zkCoins server (server-side compute is replicable; any party with the protocol can mint). This requires multiple zkCoins servers to exist; currently single-server. | +| zkCoins node malicious — refuses to generate IssuanceProof | User goes to another zkCoins node (node-side compute is replicable; any party with the protocol can mint). This requires multiple zkCoins nodes to exist; currently single-node. | ### 5.5 The "user pays an operator to mint" alternative The above puts proof generation on the user side (or their chosen -zkCoins server). A simpler MVP variant: the federation includes -zkCoins-server operators who automatically generate the IssuanceProof +zkCoins node). A simpler MVP variant: the federation includes +zkCoins-node operators who automatically generate the IssuanceProof when they see a confirmed MovetoVault. This is more centralised but operationally simpler. Trade-off documented as open question §10. @@ -540,7 +540,7 @@ A realistic implementation sequence: | 3 | Bitcoin Light Client gadget in circuit | 2–3 weeks | Phase 0 | | 4 | `IssuanceProof` circuit branch | 2 weeks | Phase 0, Phase 3 | | 5 | `BurnProof` circuit branch | 1–2 weeks | Phase 0 | -| 6 | Bridge server-side state (peg_in_consumed_smt, burned_coins_smt, pending_payouts) | 1 week | Phase 4, Phase 5 | +| 6 | Bridge node-side state (peg_in_consumed_smt, burned_coins_smt, pending_payouts) | 1 week | Phase 4, Phase 5 | | 7 | Federation node software (signer + operator + watchtower roles) | 4–6 weeks | Phase 2a, Phase 6 | | 8 | Integration testing with all federation members on signet | 2–4 weeks | Phase 7 | | 9 | Mainnet launch | TBD | Phase 8 | @@ -669,9 +669,9 @@ Zcash's t/z address model. ## 10. Open Questions -1. **Who pays for proof generation in Phase 4–5?** Server-side +1. **Who pays for proof generation in Phase 4–5?** Node-side (zkCoins operator) is operationally simpler; user-side - (decentralised) is more trustless. Default: server-side for v1 + (decentralised) is more trustless. Default: node-side for v1 with a clear migration path to user-side later. 2. **Federation size and composition.** Minimum credible: 5 diff --git a/BRIDGE_MVP.md b/BRIDGE_MVP.md index 155bfcd5..3d9bbbb8 100644 --- a/BRIDGE_MVP.md +++ b/BRIDGE_MVP.md @@ -486,7 +486,7 @@ binary files alongside `smt.bin` / `mmr.bin`. Names: - `pending_payouts.bin` Per `feedback_zkcoins_closed_test_env`, no migration code is needed — -on first server start with this code, all three files are created +on first node start with this code, all three files are created fresh. ### 6.5 Test plan (Phase 3) @@ -519,7 +519,7 @@ A daemon that: ### 7.2 Where the code lives This is **not** in `zk-coins/node` directly — it's a separate -crate that the server binary depends on. Proposed: +crate that the node binary depends on. Proposed: ``` zk-coins/node/ @@ -535,7 +535,7 @@ zk-coins/node/ ``` (Alternative: separate repo `zk-coins/bridge-signer`. MVP: keep in -the server tree to avoid premature repo proliferation. Memory note: +the node tree to avoid premature repo proliferation. Memory note: zkCoins works in `zk-coins/*` org with direct-to-develop pushes per `feedback_zkcoins_direct_develop`.) @@ -652,14 +652,14 @@ zk-coins/node/ ### 8.3 Operator flow ``` -1. Subscribe to `pending_payouts` events from server (see Phase 6) +1. Subscribe to `pending_payouts` events from node (see Phase 6) 2. On PendingAssignment with status changing to Assigned: a. Verify the burn-proof landed (zkCoins state confirms) b. Verify own BTC balance ≥ amount + fees c. Construct the Payout tx (add own input as fee, sign) d. Broadcast Payout tx to Bitcoin e. Wait for confirmation - f. Update server: payout fulfilled (txid) + f. Update node: payout fulfilled (txid) 3. Submit KickOff tx claiming vault reimbursement 4. Wait for 36-block challenge window a. If no challenge: post NoChallenge tx after timelock, retrieve @@ -702,7 +702,7 @@ Negative (essential to validate the fraud-proof game works): operator cannot produce valid Assert → Disprove fires → bond slashed. - **Operator times out on fronting:** assigned operator does not - broadcast Payout within 64 blocks → server reassigns. + broadcast Payout within 64 blocks → node reassigns. - **Network partition:** simulate Bitcoin node disconnect for an operator during KickOff → operator retries on reconnect. @@ -712,7 +712,7 @@ operator + watchtower implementations). --- -## 9. Phase 6 — Bridge-Aware Server +## 9. Phase 6 — Bridge-Aware Node ### 9.1 Goal @@ -723,7 +723,7 @@ Extend `zk-coins/node` HTTP API with peg-in and peg-out endpoints. | File | Change | | ---- | ------ | | `node/src/bridge.rs` | **new** — bridge module | -| `node/src/server.rs` | Add bridge endpoints to router | +| `node/src/router.rs` | Add bridge endpoints to router | | `node/src/runtime.rs` | Wire bridge state into runtime | ### 9.3 Endpoints @@ -736,17 +736,17 @@ GET /api/bridge/quote POST /api/bridge/peg-in/initiate Body: { recipient_zkcoins_address, denomination, refund_btc_pubkey } Returns: { deposit_taproot_address, refund_timeout_block } - Server records the pending peg-in; user makes the Bitcoin deposit. + Node records the pending peg-in; user makes the Bitcoin deposit. POST /api/bridge/peg-in/finalize Body: { deposit_txid, deposit_vout, lcp_proof_bytes } - Server verifies the LCP, runs the prover to generate + Node verifies the LCP, runs the prover to generate IssuanceProof, returns ProofId to user; user signs the commitment and POSTs it back via the standard /api/commit. POST /api/bridge/peg-out/burn Body: { source_coins[], btc_recipient_address } - Server runs the prover to generate BurnProof, returns ProofId + Node runs the prover to generate BurnProof, returns ProofId and withdrawal_nonce. GET /api/bridge/peg-out/status?nonce={nonce} @@ -758,7 +758,7 @@ POST /api/bridge/peg-out/payout-template POST /api/bridge/peg-out/fronted (Operator-only.) Notify that an operator broadcast a Payout - tx; server marks PendingPayout as Fronted. + tx; node marks PendingPayout as Fronted. ``` ### 9.4 Test plan (Phase 6) @@ -878,7 +878,7 @@ one fraud-proof challenge. - No double-spends, no stuck funds, no unauthorised mints - Each scenario covered by automated integration test in the CI pipeline -- Coverage gate maintained on all touched server/bridge code +- Coverage gate maintained on all touched node/bridge code Estimated effort: **3–4 weeks** integration + debugging, risk **medium-high** (first full-stack run; expect timing and @@ -897,14 +897,14 @@ state-machine bugs). | 3 — State extension | 1 week | Low | | 4 — MuSig2 signer | 3–4 weeks | Medium | | 5 — Operator + watchtower | 3 weeks | Medium | -| 6 — Bridge-aware server | 2 weeks | Low | +| 6 — Bridge-aware node | 2 weeks | Low | | 7 — Plonky2 → Groth16 | 3–4 weeks | Medium-high | | 8 — Integration on signet | 3–4 weeks | Medium-high | | **Total** | **21–28 weeks ≈ 5–7 months** | — | Assumes Plonky2 migration (PR #17) is complete before Phase 1 starts. If parallelised carefully, Phases 1–3 can begin while PR #17 -finishes (since they don't depend on the server-side replace step). +finishes (since they don't depend on the node-side replace step). ### 12.2 Risk register @@ -943,7 +943,7 @@ finishes (since they don't depend on the server-side replace step). testing fast. 4. **Where does `bridge-signer` live?** In-tree under - `server/crates/` or separate repo? MVP: in-tree. + `node/crates/` or separate repo? MVP: in-tree. 5. **How is the LCP checkpoint advanced?** Manual operator commit for MVP. Automation = post-MVP. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 42af8c9c..d796d673 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ This guide covers everything you need to develop, test, and deploy the zkCoins backend. -The first section, "Working on the Plonky2 Migration", documents the project invariants, the decision recipe for "should this go in the MVP?", the pre-push checklist, and the known foot-guns. It applies to all work on `develop` after the 2026-05-18 SP1 → Plonky2 cutover. The rest of this file is the dev guide for day-to-day server work. +The first section, "Working on the Plonky2 Migration", documents the project invariants, the decision recipe for "should this go in the MVP?", the pre-push checklist, and the known foot-guns. It applies to all work on `develop` after the 2026-05-18 SP1 → Plonky2 cutover. The rest of this file is the dev guide for day-to-day node work. --- @@ -31,7 +31,7 @@ documents in the order given below. ### No polling — events only -Bitcoin / Esplora signals on the server's hot path are subscribed to, +Bitcoin / Esplora signals on the node's hot path are subscribed to, never polled. The scanner consumes block events from the mempool.space-compatible WebSocket stream (`scanner_ws.rs`, `ESPLORA_WS_URL`, default `wss://mutinynet.com/api/v1/ws`); the @@ -83,7 +83,7 @@ with the rationale. The five constraints below are decided and apply across every PR on `develop`. -1. **Server-side compute architecture.** The server generates every ZK +1. **Node-side compute architecture.** The node generates every ZK proof, holds every Merkle tree, broadcasts every Taproot inscription. The wallet holds only the user's private key and signs BIP-340 Schnorr over `SHA256(serialize(asth) ‖ serialize(ocr))`. No in-browser @@ -91,8 +91,8 @@ The five constraints below are decided and apply across every PR on 2. **Closed test environment** — DEV *and* PRD. No external users, no real money, no migration of existing state. Step 7 of the ROADMAP deleted the SP1 path outright; no Cargo feature flag, no dual - backend. At cutover (PR [#17](https://github.com/zk-coins/node/pull/17), 2026-05-18) the server state files - were wiped and the new Plonky2 server started fresh. + backend. At cutover (PR [#17](https://github.com/zk-coins/node/pull/17), 2026-05-18) the node state files + were wiped and the new Plonky2 node started fresh. 3. **Hardware target: Mac Studio M3 Ultra, 96 GB unified RAM, single host.** All on-box compute resources are available (Performance + Efficiency cores, the integrated Apple GPU reachable via Metal, @@ -109,7 +109,7 @@ The five constraints below are decided and apply across every PR on from inside the affected crate. Current state on `program-plonky2`: 100% lines / functions / regions, 115 default-run tests (+ 2 `#[ignore]`d `recursion_shape_probe` diagnostics). The authoritative - coverage gate for `server` runs in CI on the self-hosted M3 Ultra + coverage gate for `node` runs in CI on the self-hosted M3 Ultra runner (`.github/workflows/ci.yaml`, `Coverage Gate` job, gated behind the `ci:full` label on PRs). See `ROADMAP.md` § "Done" for the live test count and breakdown. @@ -141,8 +141,8 @@ first "no". 1. **Is X on the critical path for the one-shot user loop?** (create account → mint → send → receive → balance) If no, defer to post-MVP. -2. **Does X compromise invariant 1 (server-side compute)?** If yes, - redesign so all heavy compute is server-side. +2. **Does X compromise invariant 1 (node-side compute)?** If yes, + redesign so all heavy compute is node-side. 3. **Does X require external hardware or cloud services (invariant 3)?** If yes, redesign. 4. **Does X assume migration logic (invariant 2)?** If yes, redesign @@ -229,7 +229,7 @@ Condensed pointers into [`MIGRATION_RESEARCH.md`](./MIGRATION_RESEARCH.md) §7: git clone https://github.com/zk-coins/node.git cd node USERNAME_DOMAIN=test.zkcoins.local cargo run -p node -# Server starts on http://0.0.0.0:4242 +# Node starts on http://0.0.0.0:4242 ``` ## Local Development with Postgres @@ -316,11 +316,11 @@ before any main-merge. ## Project Structure ``` -server/ -├── server/ # Axum REST API server +node/ +├── node/ # Axum REST API │ └── src/ │ ├── main.rs # Entry point, chain scanner, bind address -│ ├── server.rs # REST endpoints (mint, send, balance, proof) +│ ├── router.rs # REST endpoints (mint, send, balance, proof) │ ├── account_node.rs # Account management, coin proofs, prover calls │ ├── state.rs # Sparse Merkle Tree + Merkle Mountain Range │ ├── scanner.rs # Bitcoin block scanner (Taproot Inscriptions) @@ -351,8 +351,8 @@ server/ | Branch | Purpose | Deploy target | |---|---|---| -| `develop` | Default branch, active development | DEV server | -| `main` | Production releases | PRD server | +| `develop` | Default branch, active development | DEV node | +| `main` | Production releases | PRD node | - **Push to `develop` via feature branch + PR** (branch ruleset active) - **`main` is protected** — changes only via PR @@ -365,7 +365,7 @@ English, concise, *what* not *how*: ``` # Good Bind to 0.0.0.0 instead of 127.0.0.1 for Docker access -Decouple server from SP1: optional zkvm feature, stub prover +Decouple node from SP1: optional zkvm feature, stub prover Add rand features to bitcoin dependency # Bad @@ -415,7 +415,7 @@ let block = fetch_block(hash).unwrap(); ### Request Flow ``` -Client Request → Axum Router → server.rs (endpoint) → account_node.rs (logic) +Client Request → Axum Router → router.rs (endpoint) → account_node.rs (logic) ├── Prover (Plonky2) ├── State (SMT + MMR) └── Publisher (Bitcoin) @@ -423,7 +423,7 @@ Client Request → Axum Router → server.rs (endpoint) → account_node.rs (log ### Key Patterns -**Thread-safe state:** All shared state is `Arc>`. The server acquires a lock, reads/writes, releases. +**Thread-safe state:** All shared state is `Arc>`. The node acquires a lock, reads/writes, releases. **Account model:** Each account is `Address → Account` in a HashMap: ```rust @@ -444,7 +444,7 @@ Plonky2 prover. ### Bitcoin Integration -The server continuously scans the Bitcoin blockchain: +The node continuously scans the Bitcoin blockchain: 1. `scanner_ws.rs` subscribes to the mempool.space-compatible WebSocket (`ESPLORA_WS_URL`) and pushes block events into a channel; no @@ -476,15 +476,15 @@ for the historical pickup record. The node reads its configuration exclusively from environment variables; no `.env` file is loaded by the process. The table below covers every -variable the server actually reads (`node/src/lib.rs`, `runtime.rs`, +variable the node actually reads (`node/src/lib.rs`, `runtime.rs`, `scanner_ws.rs`, `publisher.rs`). Required variables panic the bootstrap on startup if unset — there is no silent fallback. | Variable | Default | Description | |---|---|---| -| `DATABASE_URL` | _(required, no default)_ | Postgres connection string for the state-layer (e.g. `postgresql://zkcoins:@postgres:5432/zkcoins`). Server panics on startup if unset. | -| `PUBLISHER_KEY` | _(required, no default)_ | 32-byte hex private key for Taproot inscription publishing. **Required on every network — DEV, signet, and mainnet.** No fallback default exists: the previous `1234…` placeholder was a publicly-known test key that drainer bots swept within minutes of any on-chain top-up (4 historical drains confirmed). Server panics on startup if unset. Generate locally via `openssl rand -hex 32`. In any deployed environment, source it from your secret manager — **never commit a real key**. | -| `USERNAME_DOMAIN` | _(required, no default)_ | External hostname returned by `/api/info`; server panics on startup if unset (see PR [#36](https://github.com/zk-coins/node/pull/36) for the regression that introduced the global panic hook). | +| `DATABASE_URL` | _(required, no default)_ | Postgres connection string for the state-layer (e.g. `postgresql://zkcoins:@postgres:5432/zkcoins`). Node panics on startup if unset. | +| `PUBLISHER_KEY` | _(required, no default)_ | 32-byte hex private key for Taproot inscription publishing. **Required on every network — DEV, signet, and mainnet.** No fallback default exists: the previous `1234…` placeholder was a publicly-known test key that drainer bots swept within minutes of any on-chain top-up (4 historical drains confirmed). Node panics on startup if unset. Generate locally via `openssl rand -hex 32`. In any deployed environment, source it from your secret manager — **never commit a real key**. | +| `USERNAME_DOMAIN` | _(required, no default)_ | External hostname returned by `/api/info`; node panics on startup if unset (see PR [#36](https://github.com/zk-coins/node/pull/36) for the regression that introduced the global panic hook). | | `POSTGRES_PASSWORD` | _(required, no default for the DB container)_ | Read by the Postgres container, not by the node process itself; the node's `DATABASE_URL` already embeds the password. Listed here because it is part of the local-dev bootstrap (see `Local Development with Postgres` below). | | `ESPLORA_URL` | `https://mutinynet.com/api` | Esplora REST API endpoint (electrs or public). | | `ESPLORA_WS_URL` | `wss://mutinynet.com/api/v1/ws` | Esplora WebSocket endpoint consumed by `scanner_ws` (issue #84). DEV/PRD override only when the upstream WS path changes. | @@ -524,14 +524,14 @@ Docker builds use nightly Rust auto-installed via the workspace `rust-toolchain` ## Persistent State -After the PR-A1/PR-A2/PR-A3 Postgres migration series, all persistent server state lives in a Postgres 17 database (`DATABASE_URL` env var). The only on-disk state remaining is the per-proof file store. The state-layer schema (`node/migrations/*.sql`) is applied idempotently on every boot by `db::connect_and_migrate`. +After the PR-A1/PR-A2/PR-A3 Postgres migration series, all persistent node state lives in a Postgres 17 database (`DATABASE_URL` env var). The only on-disk state remaining is the per-proof file store. The state-layer schema (`node/migrations/*.sql`) is applied idempotently on every boot by `db::connect_and_migrate`. | Location | Format | Purpose | | --------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `smt_state` row (singleton, `id = 1`) | bincode `SparseMerkleTree` in a `BYTEA` column | Sparse Merkle Tree of every commitment ever processed (key = sha256(public_key), leaf = account_state_hash). | | `mmr_state` row (singleton, `id = 1`) | bincode `MerkleMountainRange` in a `BYTEA` column | Append-only Merkle Mountain Range of `hash(smt_root ‖ prev_mmr_root)` leaves; one entry per processed commitment. | | `latest_block` row (singleton, `id = 1`) | 32-byte block hash in a `BYTEA` column | Last Bitcoin block whose inscriptions were fully processed and persisted. Scanner resumes from `latest_block + 1` after a restart. Written in the same `BEGIN; UPSERT; UPSERT; UPSERT; COMMIT` transaction as the SMT and MMR (issue #11 fix). | -| `accounts` table (one row per address) | 32-byte `address` PRIMARY KEY + bincode `Account` `BYTEA` | Server-side account ledger — per-address balance, coin_queue, coin_history (SMT), and latest proof. Includes the minting account. Upserted per mutation by the send / receive / mint handlers. | +| `accounts` table (one row per address) | 32-byte `address` PRIMARY KEY + bincode `Account` `BYTEA` | Node-side account ledger — per-address balance, coin_queue, coin_history (SMT), and latest proof. Includes the minting account. Upserted per mutation by the send / receive / mint handlers. | | `usernames` table (one row per name) | `TEXT` name PRIMARY KEY + 32-byte `address` `BYTEA` | Bidirectional map of claimed usernames ↔ addresses. Race-free claims via `INSERT … ON CONFLICT (name) DO NOTHING`. Always present — usernames are permanent MVP. | | `minting_meta` row (singleton, `id = 1`) | `BIGINT` num_pubkeys | Counter of how many mint commitments have been issued; **must** survive restart, otherwise the next mint sends a stale `prev_commitment_pubkey` and `send_coins` returns `prev_commitment_pubkey required for account update`. Always present — mint is permanent MVP. | | `proofs/.bin` (on-disk file) | bincode `CoinProof` | Individual per-send proof + commitment, indexed by `proof_id`. Append-only. **Not** in Postgres because the per-proof blobs are large Plonky2 proof bytes and the directory layout makes recovery trivial. Path configurable via `PROOFS_DIR` (default `./proofs`). | @@ -540,27 +540,27 @@ Writes are atomic at the row / transaction level (`ON CONFLICT DO UPDATE` for si ### DEV state recovery -If the DEV server gets into a bad state (panic loop, mint failures with `prev_commitment_pubkey required`, balance never rising after a successful mint, etc.), the recovery procedure is to truncate the Postgres state-layer tables (and drop the on-disk proofs directory): +If the DEV node gets into a bad state (panic loop, mint failures with `prev_commitment_pubkey required`, balance never rising after a successful mint, etc.), the recovery procedure is to truncate the Postgres state-layer tables (and drop the on-disk proofs directory): ```bash -# On the host running the server (DEV or PRD): +# On the host running the node (DEV or PRD): docker stop zkcoins-node # Truncate every state-layer table. _sqlx_migrations is intentionally # left in place so connect_and_migrate skips re-applying the schema. docker exec -i zkcoins-postgres psql -U zkcoins -d zkcoins -c \ 'TRUNCATE accounts, usernames, smt_state, mmr_state, latest_block, minting_meta;' # Drop the per-proof files (proof_id state resets at next boot). -docker run --rm -v zkcoins_server-data:/data alpine sh -c 'rm -rf /data/proofs' +docker run --rm -v zkcoins_node-data:/data alpine sh -c 'rm -rf /data/proofs' docker start zkcoins-node ``` -The server starts from genesis on next boot: `Loaded State from Postgres` (empty), `Loaded AccountNode from Postgres` (empty), `No saved block hash found, fetching latest from Esplora`. Past test wallets are abandoned on-chain (they're random) but the SMT is re-built from the chain tip onwards. This is **destructive** — never run it on PRD without a known-needed reason. +The node starts from genesis on next boot: `Loaded State from Postgres` (empty), `Loaded AccountNode from Postgres` (empty), `No saved block hash found, fetching latest from Esplora`. Past test wallets are abandoned on-chain (they're random) but the SMT is re-built from the chain tip onwards. This is **destructive** — never run it on PRD without a known-needed reason. The E2E regen workflow on the app repo wipes this state before every run as part of the per-PR cadence in `app/e2e/README.md § 11.3`. -### Bitcoin Node +### Bitcoin Core -The server needs a Bitcoin node with an Esplora-compatible indexer (electrs). In production, it connects via the shared Docker network `bitcoin` to `electrs-mainnet:3000` (DEV: `electrs-mutinynet:3000`). The underlying bitcoind requires: +The node needs Bitcoin Core with an Esplora-compatible indexer (electrs). In production, it connects via the shared Docker network `bitcoin` to `electrs-mainnet:3000` (DEV: `electrs-mutinynet:3000`). The underlying bitcoind requires: - `txindex=1` - `rest=1` - `server=1` diff --git a/LIGHTNING_ATOMIC_SWAP.md b/LIGHTNING_ATOMIC_SWAP.md index d6d8c7b2..f9c09abc 100644 --- a/LIGHTNING_ATOMIC_SWAP.md +++ b/LIGHTNING_ATOMIC_SWAP.md @@ -87,7 +87,7 @@ any point during the swap. Equivalently: Symmetrically for the swap provider. The "single counterparty" referred to is a swap provider (a liquidity -operator who runs both a zkCoins server and a Lightning node), analogous +operator who runs both a zkCoins node and a Lightning node), analogous to Boltz's role in BTC ↔ LN submarine swaps. --- @@ -121,14 +121,14 @@ revealing preimage `x` such that `H(x) = H`". Per `SPEC.md` §5 and §11: -1. The sender's server generates a state-transition proof (`ProofData`) +1. The sender's node generates a state-transition proof (`ProofData`) covering balance update, output coin creation, and history extension. 2. The sender's wallet signs `SHA256(serialize(asth) ‖ serialize(ocr))` with BIP-340 Schnorr. Here `asth` is the account state hash and `ocr` is the output coins root (the Merkle root of the SMT containing the send's output coin identifiers); both abbreviations match `SPEC.md`'s glossary. -3. The server (or any party with the signed `Commitment`) constructs a +3. The node (or any party with the signed `Commitment`) constructs a Taproot commit-reveal pair where the commit tx's txid hex begins with `4242`, and the reveal tx's witness contains the inscription payload (signed `Commitment`). @@ -148,23 +148,23 @@ the reveal-tx getting sufficient Bitcoin confirmations and (b) the scanner running. Until then, the send has not happened from the recipient's perspective. -### 4.3 What the wallet knows vs. what the server knows +### 4.3 What the wallet knows vs. what the node knows - **Wallet:** holds the account commitment private key; signs the Schnorr commitment over `SHA256(asth ‖ ocr)`. Holds no Poseidon state, no SMT/MMR data. -- **Server:** holds the entire state (SMT + MMR), generates proofs, +- **Node:** holds the entire state (SMT + MMR), generates proofs, holds the inscription-publishing Bitcoin wallet, runs the scanner. -This split is locked by the server-side-compute architecture decision +This split is locked by the node-side-compute architecture decision (`MIGRATION_RESEARCH.md` §5; `feedback_zkcoins_server_side_compute`). For swap design this matters because: - Anything that requires "the wallet signs after seeing something" is cheap (one round-trip to wallet). -- Anything that requires "the server constructs and signs a Bitcoin tx - that publishes the inscription" can be replaced with "the server +- Anything that requires "the node constructs and signs a Bitcoin tx + that publishes the inscription" can be replaced with "the node constructs the inscription payload and lets a different party publish". @@ -332,7 +332,7 @@ the on-chain side. account (so `recipient = H(initial_pubkey)` is known to them and the provider). - **Provider:** Lightning node with inbound liquidity from the user, - zkCoins server with sufficient inventory in some operator account, + zkCoins node with sufficient inventory in some operator account, Bitcoin wallet for funding UTXO. - **Pre-agreed:** swap amount `A` (in sats), provider fee `F`, swap timeout parameters (`T_lock` for on-chain CLTV, `T_ln` for @@ -348,7 +348,7 @@ Step 1. User generates preimage x ←$ {0,1}^256. Computes H = SHA256(x). - amount A - user_btc_refund_pubkey for the funding UTXO -Step 2. Provider's zkCoins server prepares the send: +Step 2. Provider's zkCoins node prepares the send: - Loads the operator account state - Builds out_coins with one entry: { identifier, recipient = user_zkcoins_recipient_address, amount = A } @@ -444,7 +444,7 @@ Step 11. Provider settles the LN HTLC, capturing A + F. Swap complete. | User aborts at Step 5 | Provider has funded U_lock; nothing else moved | Provider refunds U_lock at T_lock (Step 3 ELSE branch). Cost: on-chain fee for U_lock creation. | | User pays LN (Step 6) but never broadcasts commit (Step 7) | Provider has incoming LN HTLC, U_lock still locked | LN HTLC times out at T_ln, user gets LN funds back. Provider refunds U_lock at T_lock. Both whole. | | User broadcasts commit but it doesn't confirm before T_lock | User has paid LN, U_lock is being refunded by provider; user's tx might or might not eventually confirm | This is the race condition T_lock is designed to prevent. See §12. With margin, this should not happen; if it does, provider claims U_lock refund and user claims LN refund. Provider has zkCoins still in inventory (no send actually happened since inscription never landed). | -| Provider's server crashes between Step 2 and Step 4 | User has H, has not paid anything | User aborts, no loss. | +| Provider's node crashes between Step 2 and Step 4 | User has H, has not paid anything | User aborts, no loss. | | Provider's Bitcoin wallet runs out of funds for U_lock | Pre-condition failure | Provider rejects swap initiation. No loss. | | Provider refuses to settle LN at Step 11 despite preimage visible | Provider has zkCoins inventory still committed, user has zkCoins (Step 9 succeeded), preimage on-chain | LN HTLC will time out and refund to user. User keeps zkCoins **and** gets LN funds back. **Net: provider loses A+F to itself.** This is asymmetric — provider has no incentive to do this. Documented as provider-side discipline. | @@ -499,7 +499,7 @@ Step 1. Provider generates preimage x ←$ {0,1}^256. Computes - amount A - provider's LN invoice for amount A − F (standard, not hold) -Step 2. User's zkCoins server prepares the send proof to +Step 2. User's zkCoins node prepares the send proof to provider_zkcoins_recipient_address with amount A. User signs Schnorr σ over H(asth ‖ ocr) with their commitment pubkey. @@ -554,9 +554,9 @@ Step 10. Swap complete. The last failure mode of the table is worth flagging in code: if the inscription never lands, the zkCoins state never updates. The user's -server-side state shows the send as "prepared" but not "committed", +node-side state shows the send as "prepared" but not "committed", because the corresponding `Commitment` was never broadcast. The -swap-aware server must release the prepared state if it observes that +swap-aware node must release the prepared state if it observes that the corresponding U_lock' has been refunded, so the user can re-use those coins for another swap or send. @@ -756,7 +756,7 @@ the *inscription publication*, not U_lock. ### 12.5 The proof-time question -Provider's send proof generation (zkCoins server side): +Provider's send proof generation (zkCoins node side): - SP1 today: tens of seconds to a few minutes warm. - Plonky2 post-cutover target: ≤1 second warm. @@ -837,7 +837,7 @@ Typical Boltz total fees: 0.1–0.5% of swap amount + ~250 sat on-chain. Between Step 2 (provider prepares send) and Step 9 (inscription confirms), the provider's zkCoins inventory is committed but not-yet-published. The provider must not initiate another swap that -would also commit the same balance — server-side concurrency control +would also commit the same balance — node-side concurrency control required. Concretely, the operator account's "soft balance" must reflect: @@ -846,7 +846,7 @@ includes all amounts for prepared-but-not-confirmed sends. This is the "stuck inventory" problem of any submarine swap provider; Boltz solves it with parallel HTLC tracking. zkCoins-side it requires -the swap-aware server to track prepared swaps until inscription +the swap-aware node to track prepared swaps until inscription confirms (or refund completes). ### 14.4 Watching the chain @@ -1033,7 +1033,7 @@ implementation. Specifically: inscription payload, exactly the signature the scanner verifies. Implementation can therefore run in parallel to PR #17 without -contention. The swap code touches `server/` (new endpoints) and adds a +contention. The swap code touches `node/` (new endpoints) and adds a new operational component (Bitcoin script construction, LN node integration). Neither touches `program-plonky2/` or `program/`. @@ -1157,8 +1157,8 @@ A draft sequence; not a commitment. 3. **Where does the operator account's privkey live?** The Schnorr signature on H(asth ‖ ocr) (Step 2 of Flow A) needs to happen - server-side, because the operator is the sender. This means the - operator account's commitment key is server-resident. Same + node-side, because the operator is the sender. This means the + operator account's commitment key is node-resident. Same architectural assumption as for any operator-issued zkCoins coin; should be documented in ops runbook. diff --git a/MIGRATION_RESEARCH.md b/MIGRATION_RESEARCH.md index c05d124a..39f654f7 100644 --- a/MIGRATION_RESEARCH.md +++ b/MIGRATION_RESEARCH.md @@ -34,7 +34,7 @@ A Plonky2 IVC skeleton (`fn main()` with `println!` demos, no tests) that: ### What it isn't -Despite the repo name, it contains **none** of: SMT, MMR, AccountState, Coin, ProofData, Schnorr verification, recipient model, Bitcoin link, tests, server, scanner. The single `main.rs` is a Plonky2 tutorial-grade IVC demo with no zkCoins semantics. +Despite the repo name, it contains **none** of: SMT, MMR, AccountState, Coin, ProofData, Schnorr verification, recipient model, Bitcoin link, tests, node, scanner. The single `main.rs` is a Plonky2 tutorial-grade IVC demo with no zkCoins semantics. ### Adoption decisions @@ -182,7 +182,7 @@ The following decisions are taken. Each is reversible but reversing them means a 5. **Privacy (D2/D10)** → **deferred to v2.** Plaintext recipient addresses for v1. Linkability across multiple coins to the same recipient is a known limitation, called out as a mainnet blocker in SPEC §15. -6. **Fee model (D6)** → **no fee in v1.** We are the publisher (DFX/zkCoins-operated server), so there is no publisher to compensate. Self-funded operation. +6. **Fee model (D6)** → **no fee in v1.** We are the publisher (DFX/zkCoins-operated node), so there is no publisher to compensate. Self-funded operation. Hash-function boundary visualisation: @@ -211,7 +211,7 @@ Key adjustments made since the original outline: - **Step ordering of gadgets** (was: hash → SMT non-inclusion+insert → MMR-append → SHA256). Actual: MMR inclusion → SMT inclusion → SMT non-inclusion verify. The original list mentioned an MMR-append and a SHA256 gadget which turned out to not be needed (MMR is built off-circuit by the scanner; SHA256 lives at the Bitcoin-signing boundary, not in-circuit — see §5.4). - **No Cargo feature flag for dual backend.** The closed-test-environment decision means step 7 replaces SP1 with Plonky2 outright (see ROADMAP step 7). -- **Server scanner + state DO change** (Poseidon SMT/MMR, not SHA256). Only the on-chain commitment *format* — a single Schnorr inscription with txid prefix `4242` — stays unchanged. +- **Node scanner + state DO change** (Poseidon SMT/MMR, not SHA256). Only the on-chain commitment *format* — a single Schnorr inscription with txid prefix `4242` — stays unchanged. --- @@ -378,7 +378,7 @@ we're verifying commitment openings inside the predicate. ### 7.6 Tests serialised, memory-resident binaries linger — **LOW, but operationally costly** -**Discovered:** orphan `server-f8087395d1b79585` process consuming 35 GB +**Discovered:** orphan `node-f8087395d1b79585` process consuming 35 GB of swap reservation hours after `cargo test` finished. **Cause:** when a background `cargo test` is aborted (or completes but @@ -857,8 +857,8 @@ Dropped the recursive verify; kept only the SMT inclusion of the coin in the witnessed `source_output_coins_root` + SPEC §8 (c)(d)(e) chain for the source's commitment in `history_root`. Idea: the "source is a valid prior transition" property is enforced by the -trusted server only folding validly-proved commitments into the -history MMR — sufficient for server-heavy MVP. +trusted node only folding validly-proved commitments into the +history MMR — sufficient for node-heavy MVP. The outer build then failed with a different error: the cyclic fixed-point check `goal_data != common` failed at `circuit_builder.rs:1067` @@ -873,11 +873,11 @@ diverged in ways that NoopGate padding alone cannot reconcile. #### Decision -**Defer to Stage 5d-next-5 (post-MVP).** For the zkCoins server-heavy -MVP architecture (server generates all proofs, wallet holds only -private key, single trusted server), the security property "in-coin +**Defer to Stage 5d-next-5 (post-MVP).** For the zkCoins node-heavy +MVP architecture (node generates all proofs, wallet holds only +private key, single trusted node), the security property "in-coin came from a valid prior transition" can be enforced **off-circuit**: -the server only folds commitments of validly-proved transitions into +the node only folds commitments of validly-proved transitions into the history MMR. So in-circuit SMT inclusion of the coin in the witnessed `source_output_coins_root` + CMP chain for the source's commitment in `history_root` would be sufficient — but even that @@ -1256,11 +1256,11 @@ the fixed-point iteration in `common_data_for_recursion_c_inner` then needs `ConstantGate::new(2)` injection in pass 3 and `pad_bits = outer_degree - 1` to converge. -### 7.23 `MINTING_ADDRESS` panic in `tokio::spawn`-ed task swallows server bootstrap — **MEDIUM, codified** +### 7.23 `MINTING_ADDRESS` panic in `tokio::spawn`-ed task swallows node bootstrap — **MEDIUM, codified** **Discovered:** first auto-deploy of `zkcoins/node:beta` on the DEV host post-PR [#17](https://github.com/zk-coins/node/pull/17). The -container started, the REST server bound `0.0.0.0:4242`, but +container started, the REST API bound `0.0.0.0:4242`, but `https://dev-api.zkcoins.app/health` returned Cloudflare 502 for hours. `docker compose ps` showed the container as `Up (unhealthy)` — the tokio worker that owned the HTTP listener panicked on every cold boot @@ -1271,7 +1271,7 @@ processing blocks. No restart, no monitor, no visible failure in **Root cause:** the Plonky2 migration moved `MINTING_ADDRESS` to a well-known constant (`hash_bytes(b"zkcoins:minting-address:placeholder:v1")` in `program-plonky2/src/types.rs`). The SP1-era `ClientAccount::new` -in `server` still derived `address` from the privkey's first child +in `node` still derived `address` from the privkey's first child pubkey; the `assert_eq!` in `start_rest_node` between the two could never hold again. **And** a panic inside a `tokio::spawn`-ed task by default only kills the task — the process happily continued in zombie @@ -1302,7 +1302,7 @@ state for 8 h with the listener dead and the scanner alive. PR loses its green check, and the regression surfaces immediately instead of hours later. Mirrored to deploy-prd in PR [#51](https://github.com/zk-coins/node/pull/51). -**Lesson:** in async server code, NEVER let a spawned task panic +**Lesson:** in async node code, NEVER let a spawned task panic silently. Either install a global panic hook (the cheap fix taken here) or wrap every spawned future in a `Result`-returning closure that explicitly propagates the panic to the main task via a watcher diff --git a/MULTI_ASSET.md b/MULTI_ASSET.md index cbacba7f..37cdfc2f 100644 --- a/MULTI_ASSET.md +++ b/MULTI_ASSET.md @@ -37,7 +37,7 @@ zkCoins today serves one asset: the faucet-minted unit returned by `/api/mint`. The minting account is hard-coded (`MINTING_ADDRESS`, see [`SPEC.md`](./SPEC.md) §8 "Note on the minting account"), the `Invoice` and `Coin` types carry only `amount + recipient`, and the -account-server's `balance: u64` is a single scalar. +account-node's `balance: u64` is a single scalar. Multi-asset opens this to any user: anyone mints a new token under a chosen name, distributes it, and retains the right to issue more. @@ -73,7 +73,7 @@ means a non-trivial protocol-level change. | # | Decision | Consequence | | - | -------- | ----------- | -| **M1** | **Token creation is permissionless.** Any account can call `/api/asset/create` and mint a new asset. No whitelist, no admin gate, no fee gate. | The server is a pass-through registrar. Spam pressure is handled by the on-chain inscription fee on the genesis transaction's `Commitment`, not by the server. | +| **M1** | **Token creation is permissionless.** Any account can call `/api/asset/create` and mint a new asset. No whitelist, no admin gate, no fee gate. | The node is a pass-through registrar. Spam pressure is handled by the on-chain inscription fee on the genesis transaction's `Commitment`, not by the node. | | **M2** | **Creator retains ongoing mint authority.** The asset's genesis transaction pins a `mint_authority_pubkey` (the creator's compressed secp256k1 pubkey). Subsequent `/api/mint` calls require a fresh Schnorr signature verifiable against that pubkey. No fixed-supply rule. | No "burn the key after genesis" mode. Total supply is open-ended; trust in the asset is trust in the creator not to over-issue. Key rotation is out of scope (see §11, §12.7). | | **M3** | **Asset namespace is first-come-first-served on `name`.** The first genesis transaction binding a given `name` wins; later attempts return `409 Conflict`. Normalisation is `name.to_lowercase()` to remove the cheapest look-alike attacks; the trade-off is documented in §10. | `assets.name UNIQUE` at the SQL layer is the enforcement point. No retroactive renaming, no namespace governance. | | **M4** | **Privacy pool is a single shared SMT.** `asset_id` is a public field on each coin commitment and a public input on each state-transition proof. Anonymity-set is per-asset (all `asset_id = X` traffic mixes; `asset_id = Y` is a separate pool). | Circuit complexity unchanged modulo one extra public input + one cross-coin equality constraint. Per-asset trees and per-asset MMRs are deferred. | @@ -239,7 +239,7 @@ The genesis carries five things into the world: After genesis, the asset creator may issue further units by calling `/api/mint { asset_id, recipient, amount, signature, timestamp }`. -The server: +The node: 1. Looks up `AssetMeta` by `asset_id`. Rejects if unknown. 2. Verifies the BIP-340 Schnorr signature over @@ -248,7 +248,7 @@ The server: `mint_authority_pubkey`. 3. Rejects if the timestamp is older than 300 s or in the future — matches the existing replay window in - `verify_send_signature` (`node/src/server.rs`). + `verify_send_signature` (`node/src/router.rs`). 4. Runs the prover to produce a state-transition proof that moves `amount` units of `asset_id` from the asset's mint-authority account into a fresh coin for `recipient`. The same circuit @@ -256,7 +256,7 @@ The server: in-circuit signature gate fires against `mint_authority_pubkey` instead of the sender's commitment pubkey (see §5). -The current `/api/mint` is permissioned only by the server's +The current `/api/mint` is permissioned only by the node's faucet config (`feature = "faucet"`, `MINTING_ADDRESS` hard-coded); under multi-asset it becomes a signed request from any creator for their own asset. @@ -278,7 +278,7 @@ H("zkcoins:send" Existing wallets sign over `SHA256(account_address || recipient || amount_le || timestamp_le)` with **no** domain prefix — see -`verify_send_signature` in `node/src/server.rs`. The multi-asset +`verify_send_signature` in `node/src/router.rs`. The multi-asset upgrade does two things to this hash: 1. **Adds `asset_id`** between `amount_le` and `timestamp_le`. @@ -305,7 +305,7 @@ twice — defense in depth, matching the pattern in `node/src/account_node.rs::send_coins` (off-circuit pre-check) and `program-plonky2/src/circuit/main.rs` (in-circuit constraint): -- **Off-circuit (server pre-check):** before paying prove cost, +- **Off-circuit (node pre-check):** before paying prove cost, iterate `account.coin_queue` and `invoices`, assert every `asset_id` equals the transition's claimed `asset_id`. Reject with `400 Mixed assets in single transition` on mismatch. @@ -423,13 +423,13 @@ constraint becomes "the request is signed by the asset's Two viable architectures, mirroring the recurring trade-off in `SPEC.md` §12.6: -1. **Off-circuit Schnorr verify (preferred for v1).** The server +1. **Off-circuit Schnorr verify (preferred for v1).** The node verifies the BIP-340 Schnorr signature with the existing `secp.verify_schnorr` call (the same path used by - `verify_send_signature` in `node/src/server.rs`), and the + `verify_send_signature` in `node/src/router.rs`), and the in-circuit branch only enforces that the proof's `mint_authority_pubkey` public input matches the - asset-registry-stored value. The asset registry is server state, + asset-registry-stored value. The asset registry is node state, not on-chain state — the mainnet hardening track decides whether this is acceptable (it is for the closed test environment per invariant 2 of [`CONTRIBUTING.md`](./CONTRIBUTING.md)). @@ -569,14 +569,14 @@ migration window, see §6.3). ### 6.3 Migration notes Per [`CONTRIBUTING.md`](./CONTRIBUTING.md) invariant 2 ("Closed -test environment — DEV *and* PRD"), the cutover wipes server state +test environment — DEV *and* PRD"), the cutover wipes node state and starts fresh. No live-migration logic. The recovery procedure from `CONTRIBUTING.md` § "DEV state -recovery" applies as written: stop the server, truncate every +recovery" applies as written: stop the node, truncate every state-layer table (now including `assets`), drop the proofs directory, restart. The pre-multi-asset coins are abandoned on-chain -(they're random test data); the new server starts at genesis with +(they're random test data); the new node starts at genesis with an empty `assets` table. PR-A1/A2/A3 already left DEV and PRD with empty Postgres state @@ -677,7 +677,7 @@ Suggested handler name: `asset_info_handler`. ### 7.4 `POST /api/mint` (modified) The current faucet semantics -(`feature = "faucet"`, no signature required because the server is +(`feature = "faucet"`, no signature required because the node is the minter) are removed. The new shape: ``` @@ -774,7 +774,7 @@ on both DEV and PRD anyway) but is functionally subsumed by ## 8. Wallet (client) impact -This document is server-centric. The wallet (`zk-coins/app`) +This document is node-centric. The wallet (`zk-coins/app`) adapts in four places; full design is out of scope here. - **Per-asset balance display.** The wallet's home screen renders a @@ -887,7 +887,7 @@ The mechanics behind decision M2. `SHA256("zkcoins:mint" || asset_id || recipient || amount_le || timestamp_le)`, verified against the asset's `mint_authority_pubkey`. Same secp256k1 primitive as the send - signature (`verify_send_signature` in `node/src/server.rs`); no + signature (`verify_send_signature` in `node/src/router.rs`); no new crypto primitive. - **Replay protection.** 5-minute timestamp window (`now.abs_diff(timestamp) > 300 → reject`), matching the @@ -977,7 +977,7 @@ client-visible breaking change in the upgrade. - **Trade-off:** invariant 2 (closed test environment, DEV and PRD) makes the capability-flag approach safe — there are no external wallets to worry about, and the wallet - (zk-coins/app) and server roll out together in lockstep. + (zk-coins/app) and node roll out together in lockstep. Adding a version field is belt-and-braces that costs nothing but pollutes the JSON. Recommend keeping capability-flag only unless the maintainer wants the safety net. @@ -1031,7 +1031,7 @@ without a prefix. ### 12.6 Off-circuit vs in-circuit Schnorr for the mint branch §5.3 picks off-circuit Schnorr verify for the mint and genesis -branches. The asset registry is server state, not on-chain state. +branches. The asset registry is node state, not on-chain state. - **Choice in doc:** off-circuit verify via existing `secp.verify_schnorr`. The in-circuit branch only enforces @@ -1039,7 +1039,7 @@ branches. The asset registry is server state, not on-chain state. registry value. - **Alternative:** in-circuit BIP-340 Schnorr-on-secp256k1 gadget. Verifies the mint signature inside the proof itself; - removes the server-state trust assumption. + removes the node-state trust assumption. - **Trade-off:** in-circuit Schnorr-on-secp256k1 is non-trivial in Plonky2 (`MIGRATION_RESEARCH.md` §5.4 has the analysis). For the closed test environment (invariant 2), off-circuit @@ -1113,12 +1113,12 @@ per the convention in `BRIDGE_MVP.md` §12.1. | Phase | Scope | Effort | Risk | | ----- | ----- | ------ | ---- | -| **P1 — Shared types + AssetId plumbing** | `shared/src/lib.rs` gains `AssetId`, `AssetMeta`; `Invoice` gains `asset_id`; `program-plonky2/src/types.rs::Coin`/`CoinTemplate` gain `asset_id`. No behaviour change yet — the field is propagated but the server defaults it to a placeholder `DEFAULT_ASSET_ID` so existing tests pass unchanged. Drop in a `MULTI_ASSET_FIXME` comment at every site that will need real handling in P5. | **S** | Low — mechanical | +| **P1 — Shared types + AssetId plumbing** | `shared/src/lib.rs` gains `AssetId`, `AssetMeta`; `Invoice` gains `asset_id`; `program-plonky2/src/types.rs::Coin`/`CoinTemplate` gain `asset_id`. No behaviour change yet — the field is propagated but the node defaults it to a placeholder `DEFAULT_ASSET_ID` so existing tests pass unchanged. Drop in a `MULTI_ASSET_FIXME` comment at every site that will need real handling in P5. | **S** | Low — mechanical | | **P2 — Circuit extension** | `program-plonky2/src/circuit/main.rs`: bump `N_PROOF_DATA_PUBLIC_INPUTS` to 20, add `asset_id` public input, add per-slot masked-equality gates, extend `calculate_coin_identifier`. Re-run `recursion_shape_probe::dump_phase_2a_pad_bits_sweep` to confirm padding still fits. Coverage gate stays at 100%. The single heaviest lift. | **L** | Medium — cyclic-recursion padding may shift | | **P3 — Asset registry endpoints** | `POST /api/asset/create`, `GET /api/asset/list`, `GET /api/asset/info/:id_or_name`. New `assets` table migration. SQL `name UNIQUE` enforcement. Handler tests for the 409-on-conflict race. | **M** | Low — standard HTTP API extension | | **P4 — Mint signature verification** | `POST /api/mint` switches from faucet to signed creator-mint. Per-asset `num_pubkeys` counter. The faucet shortcut is removed; the always-on `Capabilities.faucet` is rewired to `multi_asset`. | **M** | Medium — replaces a known-good code path; tests must cover the per-asset replay protection | | **P5 — Send + balance + commit shape** | `POST /api/send` extends signed message, `GET /api/balance` becomes per-asset map, single-asset off-circuit pre-check enforces M5, `Capabilities.multi_asset = true`. Backfill the `MULTI_ASSET_FIXME` sites from P1. | **L** | Medium — multiple coupled changes, all wallet-visible | -| **P6 — Wallet adaptation** | `zk-coins/app`: balance display, send-flow asset picker, create-asset UX. Separate PR(s) in the app repo, gated on `Capabilities.multi_asset` from the server's `/api/info`. | **L** | Medium — UX-heavy, parallel to server work | +| **P6 — Wallet adaptation** | `zk-coins/app`: balance display, send-flow asset picker, create-asset UX. Separate PR(s) in the app repo, gated on `Capabilities.multi_asset` from the node's `/api/info`. | **L** | Medium — UX-heavy, parallel to node work | **Aggregate effort: M + L + M + M + L + L ≈ 4 person-months at full focus.** Phase 1 can begin immediately; Phase 2 is the heavy @@ -1185,7 +1185,7 @@ So nobody scope-creeps: - `node/src/account_node.rs` — `Account`, `send_coins`, the off-circuit pre-check pattern that the new single-asset invariant follows. -- `node/src/server.rs` — `verify_send_signature` (mint signature +- `node/src/router.rs` — `verify_send_signature` (mint signature follows the same 5-minute replay window and message-hash pattern), `Capabilities`. diff --git a/README.md b/README.md index 136b8b3c..988a4bf1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Container images: **[hub.docker.com/r/zkcoins/node](https://hub.docker.com/r/zkc | --------------- | -------------------- | ---------------------------------------------------- | | Language | Rust nightly | Required for Plonky2 (`feature(specialization)`) | | Web framework | Axum | Built on Tokio, idiomatic async Rust | -| ZK Proofs | Plonky2 + Poseidon-Goldilocks (cyclic recursion) | Server-side, no zkVM, no external prover dependency | +| ZK Proofs | Plonky2 + Poseidon-Goldilocks (cyclic recursion) | Node-side, no zkVM, no external prover dependency | | Data structures | SMT + MMR (Poseidon) | Non-inclusion proofs + append-only history | | Bitcoin | Taproot Inscriptions | 64-byte nullifiers, Esplora API scanning | | Bitcoin index | electrs (Esplora) | Esplora REST API via shared Docker network `bitcoin` | @@ -29,7 +29,7 @@ Full rationale: [docs.zkcoins.app/tech-decisions](https://docs.zkcoins.app/tech- ## Trust Model -Proof generation runs **inside this server process**. `AccountNode::send_coins` (`node/src/account_node.rs`) calls `self.prover.prove_account_update_with_in_and_out_coins_and_sources(...)` (and the `prove_initial_*` variant for first-time accounts) on every send / receive / mint. ZK proving requires the full private witness, so the server sees, in cleartext: +Proof generation runs **inside this node process**. `AccountNode::send_coins` (`node/src/account_node.rs`) calls `self.prover.prove_account_update_with_in_and_out_coins_and_sources(...)` (and the `prove_initial_*` variant for first-time accounts) on every send / receive / mint. ZK proving requires the full private witness, so the node sees, in cleartext: - Sender, recipient, and amount of every coin movement - The complete in-coin / out-coin / source-aggregator slot layout per account @@ -37,7 +37,7 @@ Proof generation runs **inside this server process**. `AccountNode::send_coins` - Usernames and their bound coin sets (`UsernameStore`) - Postgres rows persisting all of the above (`node/migrations/000{1,2}_*.sql`) -The **on-chain footprint stays private** — Plonky2 ensures that the public outputs (nullifiers, history roots, Taproot inscriptions) carry no readable transaction data. Block explorers and chain analytics see only opaque 64-byte commitments. The trust boundary is therefore the **server operator**, not the chain. +The **on-chain footprint stays private** — Plonky2 ensures that the public outputs (nullifiers, history roots, Taproot inscriptions) carry no readable transaction data. Block explorers and chain analytics see only opaque 64-byte commitments. The trust boundary is therefore the **node operator**, not the chain. | | Hosted (`api.zkcoins.app`) | Self-hosted | | --- | --- | --- | @@ -45,7 +45,7 @@ The **on-chain footprint stays private** — Plonky2 ensures that the public out | Operator sees plaintext transaction data | ❌ Yes — DFX runs the hosted node | ✅ No | | Setup effort | ✅ None | ⚠️ Postgres + electrs + Bitcoin node | -**If you need full transaction privacy, run your own server.** Every release is shipped as `zkcoins/node:latest` (see [Live](#live)), the build recipe is [`Dockerfile`](./Dockerfile), and runtime knobs are documented in [Configuration](#configuration). Point the [zkcoins.app](https://zkcoins.app) client at your self-hosted instance for end-to-end self-custody of transaction data. +**If you need full transaction privacy, run your own node.** Every release is shipped as `zkcoins/node:latest` (see [Live](#live)), the build recipe is [`Dockerfile`](./Dockerfile), and runtime knobs are documented in [Configuration](#configuration). Point the [zkcoins.app](https://zkcoins.app) client at your self-hosted instance for end-to-end self-custody of transaction data. ## Contributing @@ -91,7 +91,7 @@ API endpoints, background services, their activation status, and the tests that ¹ `NETWORK_NAME` env var controls the string returned. `IS_MAINNET=true` flips the default to `"Mainnet"`. ² Proof generation routes through the Plonky2 cyclic-recursion circuit. Single host, single Rust process — no zkVM, no external prover service. Mac Studio M3 Ultra is the production hardware target (96 GB unified memory, no external GPU). See [Proving Strategy](#proving-strategy). -³ Requires `PUBLISHER_KEY` set to a real funded key and `ESPLORA_URL` reachable. With the default test key the server panics on `IS_MAINNET=true` startup; on testnet it accepts the call but broadcast will fail without funded UTXOs — DEV and PRD both return `503 SERVICE_UNAVAILABLE` to the client on broadcast failure (the historic `DEV_SKIP_BROADCAST_FAILURE` env-gate that silently swallowed these failures was removed once DEV and PRD were unified on the MVP-only binary; the DEV publisher wallet therefore has to be funded for E2E paths). +³ Requires `PUBLISHER_KEY` set to a real funded key and `ESPLORA_URL` reachable. With the default test key the node panics on `IS_MAINNET=true` startup; on testnet it accepts the call but broadcast will fail without funded UTXOs — DEV and PRD both return `503 SERVICE_UNAVAILABLE` to the client on broadcast failure (the historic `DEV_SKIP_BROADCAST_FAILURE` env-gate that silently swallowed these failures was removed once DEV and PRD were unified on the MVP-only binary; the DEV publisher wallet therefore has to be funded for E2E paths). ⁴ Scanner depends on `ESPLORA_URL` (REST, used for the per-block `get_block_txids` / `get_tx` lookups and for the post-reconnect tip anchor) AND `ESPLORA_WS_URL` (WebSocket, used by `scanner_ws` to receive new-tip events — issue #84). Both default to mutinynet endpoints; on connection failure the WS subscriber reconnects with exponential backoff capped at 30 s. ### Cargo features @@ -126,7 +126,7 @@ Features tagged `mvp` whose current test coverage is insufficient — these bloc #### Network info - **Module:** `router.rs::info_handler` -- **Behaviour:** returns `{ network, capabilities: { address_list, faucet, usernames, lnurl }, username_domain }`. `network` defaults to `Mutinynet` when `IS_MAINNET=false`, `Mainnet` when `true`. `capabilities.{address_list,lnurl}` each reflect whether the corresponding Cargo feature was compiled into this binary, letting clients gate UI on a single server-side source of truth instead of parallel build-time env flags. `capabilities.{faucet,usernames}` are hardcoded `true` — mint and usernames are permanent MVP — and are retained only for back-compat with wallet clients that deserialise the shape. `username_domain` is the external hostname this server serves; **required env var** (server panics on startup if unset). PRD sets `USERNAME_DOMAIN=zkcoins.app`, DEV sets `USERNAME_DOMAIN=dev.zkcoins.app` — distinct from `network` because the same chain can be served from two isolated external hostnames, and the client renders `@` from this field +- **Behaviour:** returns `{ network, capabilities: { address_list, faucet, usernames, lnurl }, username_domain }`. `network` defaults to `Mutinynet` when `IS_MAINNET=false`, `Mainnet` when `true`. `capabilities.{address_list,lnurl}` each reflect whether the corresponding Cargo feature was compiled into this binary, letting clients gate UI on a single node-side source of truth instead of parallel build-time env flags. `capabilities.{faucet,usernames}` are hardcoded `true` — mint and usernames are permanent MVP — and are retained only for back-compat with wallet clients that deserialise the shape. `username_domain` is the external hostname this node serves; **required env var** (node panics on startup if unset). PRD sets `USERNAME_DOMAIN=zkcoins.app`, DEV sets `USERNAME_DOMAIN=dev.zkcoins.app` — distinct from `network` because the same chain can be served from two isolated external hostnames, and the client renders `@` from this field - **Tests:** `router.rs::tests::info_returns_network_name_capabilities_and_username_domain`, `router.rs::tests::info_serialization_format_is_stable` #### Get balance @@ -143,8 +143,8 @@ Features tagged `mvp` whose current test coverage is insufficient — these bloc #### Mint coins (single-phase) -- **Module:** `router.rs::mint_handler` → `account_node.rs::send_coins` with the server-held minting account -- **Behaviour:** server signs commitment itself (no client roundtrip) using the minting key +- **Module:** `router.rs::mint_handler` → `account_node.rs::send_coins` with the node-held minting account +- **Behaviour:** node signs commitment itself (no client roundtrip) using the minting key - **Proof generation:** `zkcoins_prover::Prover` (the Plonky2 wrapper in [`script-plonky2/`](./script-plonky2/)) — `prove_initial` for new accounts, `prove_account_update` for receivers - **Tests:** `account_node.rs::tests::test_create_minting_account`, `test_mint_single_invoice`, `test_mint_repro_live_setup` @@ -221,8 +221,8 @@ Features tagged `mvp` whose current test coverage is insufficient — these bloc | `ESPLORA_WS_URL` | `wss://mutinynet.com/api/v1/ws` | Esplora WebSocket endpoint consumed by `scanner_ws` (issue #84). Override only when the upstream WS path changes | | `IS_MAINNET` | `false` | `true` for Bitcoin Mainnet, `false` for Mutinynet/Signet | | `NETWORK_NAME` | `Mutinynet` / `Mainnet` | Human-readable name returned by `/api/info`. Default depends on `IS_MAINNET` | -| `USERNAME_DOMAIN` | _(required, no default)_ | External hostname returned by `/api/info`. The client renders `@` from this. **Server panics on startup if unset.** PRD sets `zkcoins.app`, DEV sets `dev.zkcoins.app` — silent fallback would let a misconfigured stage reproduce the cross-network routing bug (#95) | -| `PUBLISHER_KEY` | test key | 32-byte hex private key for inscription publishing. **Required on mainnet** — server panics on startup if default test key is detected with `IS_MAINNET=true` | +| `USERNAME_DOMAIN` | _(required, no default)_ | External hostname returned by `/api/info`. The client renders `@` from this. **Node panics on startup if unset.** PRD sets `zkcoins.app`, DEV sets `dev.zkcoins.app` — silent fallback would let a misconfigured stage reproduce the cross-network routing bug (#95) | +| `PUBLISHER_KEY` | test key | 32-byte hex private key for inscription publishing. **Required on mainnet** — node panics on startup if default test key is detected with `IS_MAINNET=true` | | `RUST_LOG` | `info` | Log level | Runtime config above shapes _behaviour_ of compiled-in routes. _Which_ routes are compiled in is decided at build time by Cargo features — see [Cargo features](#cargo-features). @@ -231,7 +231,7 @@ Runtime config above shapes _behaviour_ of compiled-in routes. _Which_ routes ar Spawned from `main.rs::main`: -1. **REST server** (`tokio::spawn` of `start_rest_node`) — Axum app bound to `0.0.0.0:4242` +1. **REST API** (`tokio::spawn` of `start_rest_node`) — Axum app bound to `0.0.0.0:4242` 2. **Block scanner** (driven directly in main, not spawned) — `scan_for_inscriptions` consumes new tips from the WS-fed `mpsc` channel produced by `scanner_ws::run_scanner_ws` (spawned as a tokio task at startup) and writes state on each verified commitment. No fixed-interval polling — see [issue #84](https://github.com/zk-coins/node/issues/84) ### Tests @@ -264,18 +264,18 @@ Requires access to a Bitcoin node. See [Backend docs](https://docs.zkcoins.app/i ```bash cargo run -p node -# Server starts on http://0.0.0.0:4242 +# Node starts on http://0.0.0.0:4242 ``` ## Two-Phase Send Flow -User sends require a two-phase flow because the server doesn't hold sender private keys: +User sends require a two-phase flow because the node doesn't hold sender private keys: -1. **`POST /api/send`** — server generates ZK proof, returns `proof_id` + `account_state_hash` + `output_coins_root` +1. **`POST /api/send`** — node generates ZK proof, returns `proof_id` + `account_state_hash` + `output_coins_root` 2. **Client signs commitment** — `Schnorr(hash_concat(account_state_hash, output_coins_root))` with BIP-32 key at `numPubkeys` -3. **`POST /api/commit`** — server verifies commitment, broadcasts Taproot inscription, delivers coin to recipient via `receive_coin` +3. **`POST /api/commit`** — node verifies commitment, broadcasts Taproot inscription, delivers coin to recipient via `receive_coin` -Mint uses a single-phase flow (server holds the minting account key). +Mint uses a single-phase flow (node holds the minting account key). ## Project Structure @@ -319,15 +319,15 @@ Docker builds use nightly Rust auto-installed via `rust-toolchain` (no external | Workflow | Trigger | Action | | ---------------------- | ------------ | ---------------------------------------------------- | -| `deploy-dev.yaml` | Push develop | Docker (ARM64) → `zkcoins/node:beta` → DEV server | -| `deploy-prd.yaml` | Push main | Docker (ARM64) → `zkcoins/node:latest` → PRD server | +| `deploy-dev.yaml` | Push develop | Docker (ARM64) → `zkcoins/node:beta` → DEV node | +| `deploy-prd.yaml` | Push main | Docker (ARM64) → `zkcoins/node:latest` → PRD node | | `auto-release-pr.yaml` | Push develop | Creates Release PR (develop → main) | Build time: ~5 minutes (Rust compilation on ARM64). ## Proving Strategy -zkCoins is **server-heavy**: a single trusted server generates all proofs, the wallet holds only the private key and signs BIP-340 Schnorr over `SHA256(serialize(asth) ‖ serialize(ocr))`. There is no in-browser Poseidon, no wasm-Plonky2 verifier, no in-app ZK gadget. See [`SPEC.md`](./SPEC.md) §13 + the memory `feedback_zkcoins_server_side_compute` for the full rationale. +zkCoins is **node-heavy**: a single trusted node generates all proofs, the wallet holds only the private key and signs BIP-340 Schnorr over `SHA256(serialize(asth) ‖ serialize(ocr))`. There is no in-browser Poseidon, no wasm-Plonky2 verifier, no in-app ZK gadget. See [`SPEC.md`](./SPEC.md) §13 + the memory `feedback_zkcoins_server_side_compute` for the full rationale. **Hardware target: Mac Studio M3 Ultra** (96 GB unified RAM, single host). All on-box compute is available: Performance + Efficiency cores, the integrated Apple Silicon GPU (via Metal — currently unused because Plonky2 ships CPU + CUDA backends only), Neural Engine, AMX. **Not available**: external GPU accelerators (no NVIDIA, no CUDA), no cloud prover services (no Succinct Prover Network, no AWS GPU). Performance budget is what the M3 Ultra delivers; if a design overshoots, the design changes — we do not add external hardware. @@ -364,7 +364,7 @@ and `ROADMAP.md`. ## Protocol -Based on [Shielded CSV](https://eprint.iacr.org/2025/068) by Jonas Nick (Blockstream), Liam Eagen (Alpen Labs), Robin Linus (ZeroSync). Server code derived from [ZeroSync/ZKCoins](https://github.com/ZeroSync/ZKCoins). +Based on [Shielded CSV](https://eprint.iacr.org/2025/068) by Jonas Nick (Blockstream), Liam Eagen (Alpen Labs), Robin Linus (ZeroSync). Node code derived from [ZeroSync/ZKCoins](https://github.com/ZeroSync/ZKCoins). ## License diff --git a/ROADMAP.md b/ROADMAP.md index 657e1779..fc969b7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,9 +36,9 @@ person-days at full focus; multiply for part-time work. | 4d | Port `ProgramInputs` + `CommitmentMerkleProofs` types | ✅ done | — | — | | 5 | Monolithic state-transition circuit (recursion, padding, vk-pin) | ✅ done (5a/5b/5c/5c+/5d/5d-next-3/5d-next-5). Stage 5d-next-5 source-side cyclic verify landed via PR [#23](https://github.com/zk-coins/node/pull/23) — aggregator pattern + Phase 2b per-slot SMT inclusion + SPEC §8 (c)(d)(e) chain + 3 §13 negatives. See [`MIGRATION_RESEARCH.md` §7.22](./MIGRATION_RESEARCH.md#722-stage-5d-next-5-source-side-verification-via-aggregator-pattern--codified-resolves-721) for the empirical insights (`ConstantGate::new(2)` injection + `helper_degree = pad_bits + 1` sweep). | — | — | | 6 | `script-plonky2/` host-side prover wrapper | ✅ done (`d96bb62`) | — | — | -| 7 | Server: **replace** SP1 path with Plonky2 (no feature flag, no dual backend) | ✅ done — `send_coins` performs **in-circuit source-side validation via Stage 5d-next-5 Phase 2 aggregator** (PR [#23](https://github.com/zk-coins/node/pull/23)); off-circuit pre-checks retained as defense-in-depth (microsecond-level fast-fail before the minute-scale prove). Initial server cut (`c71c9fc`) ran off-circuit-only because Phase 2 was deferred; the in-circuit wiring landed via the Step-7 follow-up. Dockerfile re-introduced (`dac0179`). 106 server tests pass on the MVP build, 119 with `--all-features` (32 baseline + 10 inline error-path in `d6a3cb9` + 64 ported SP1-era fixtures re-enabled in `account_node_tests.rs` / `router_tests.rs` + 13 feature-gated). Smoke-test verified end-to-end (`cargo run` + `/health` + `/api/info`, block scanner connects). | — | — | -| 8 | App / wallet: Schnorr-signing boundary, server-API integration | ✅ done — `zk-coins/app` ships `wasm.createCommitment(xpriv, num_pubkeys, asth_hex, ocr_hex)` (in `app/rust/client/src/lib.rs`) signing `SHA256(asth ‖ ocr)` via BIP-340 Schnorr (D11). Two-phase send: `/api/send` (Phase 1, proof) → `/api/commit` (Phase 2, signature). API client in `app/src/lib/api/client.ts` covers `info` / `balance` / `send` / `commit` / `mint` / `username/*` endpoints exactly matching server routes registered at `node/src/server.rs:1261–1289`. WASM mock + Vitest coverage gate already enforced in app repo. | — | — | -| 9 | DEV deployment + end-to-end roundtrip on signet | 🟡 DEV live — PR [#17](https://github.com/zk-coins/node/pull/17) merged 2026-05-18 21:50 UTC; auto-deploy via `.github/workflows/deploy-dev.yaml` landed `zkcoins/node:beta` on `dev-api.zkcoins.app`. `/health` → 200 `ok`; `/api/info` → 200 with `{network:"Mutinynet", capabilities:{address_list, faucet, usernames, lnurl: true}, username_domain:"dev.zkcoins.app"}` (post-[#73](https://github.com/zk-coins/node/pull/73) `address_list` and `lnurl` are `false` because DEV ships the MVP-only binary identical to PRD; `faucet` and `usernames` are hardcoded `true` — mint and usernames are permanent MVP, not feature-gated; the `usernames` Cargo feature was later removed outright — see PR [#76](https://github.com/zk-coins/node/pull/76)). Bootstrap-unblock fix in PR [#36](https://github.com/zk-coins/node/pull/36) (explicit `MINTING_ADDRESS` override + global panic hook + smoke test + deploy-dev post-curl-retry; see [`MIGRATION_RESEARCH.md` §7.23](./MIGRATION_RESEARCH.md#723-minting_address-panic-in-tokiospawn-ed-task-swallows-server-bootstrap--medium-codified)). Deploy concurrency guards + PRD smoke test in PR [#51](https://github.com/zk-coins/node/pull/51). DEV/PRD parity (drop DEV-only Cargo features + remove `DEV_SKIP_BROADCAST_FAILURE` env-gate) in PR [#73](https://github.com/zk-coins/node/pull/73). **Remaining:** ① e2e roundtrip (create account → mint → send → receive) on signet from `dev.zkcoins.app`; ② R2 measurement on M3 Ultra (warm ≤ 5 s, ideal ≤ 1 s; cold ≤ 30 s; peak mem < 64 GB); ③ reactive: redesign per R2 if the budget is missed. | 2–4 d | medium | +| 7 | Node: **replace** SP1 path with Plonky2 (no feature flag, no dual backend) | ✅ done — `send_coins` performs **in-circuit source-side validation via Stage 5d-next-5 Phase 2 aggregator** (PR [#23](https://github.com/zk-coins/node/pull/23)); off-circuit pre-checks retained as defense-in-depth (microsecond-level fast-fail before the minute-scale prove). Initial node cut (`c71c9fc`) ran off-circuit-only because Phase 2 was deferred; the in-circuit wiring landed via the Step-7 follow-up. Dockerfile re-introduced (`dac0179`). 106 node tests pass on the MVP build, 119 with `--all-features` (32 baseline + 10 inline error-path in `d6a3cb9` + 64 ported SP1-era fixtures re-enabled in `account_node_tests.rs` / `router_tests.rs` + 13 feature-gated). Smoke-test verified end-to-end (`cargo run` + `/health` + `/api/info`, block scanner connects). | — | — | +| 8 | App / wallet: Schnorr-signing boundary, node-API integration | ✅ done — `zk-coins/app` ships `wasm.createCommitment(xpriv, num_pubkeys, asth_hex, ocr_hex)` (in `app/rust/client/src/lib.rs`) signing `SHA256(asth ‖ ocr)` via BIP-340 Schnorr (D11). Two-phase send: `/api/send` (Phase 1, proof) → `/api/commit` (Phase 2, signature). API client in `app/src/lib/api/client.ts` covers `info` / `balance` / `send` / `commit` / `mint` / `username/*` endpoints exactly matching API routes registered at `node/src/router.rs:1261–1289`. WASM mock + Vitest coverage gate already enforced in app repo. | — | — | +| 9 | DEV deployment + end-to-end roundtrip on signet | 🟡 DEV live — PR [#17](https://github.com/zk-coins/node/pull/17) merged 2026-05-18 21:50 UTC; auto-deploy via `.github/workflows/deploy-dev.yaml` landed `zkcoins/node:beta` on `dev-api.zkcoins.app`. `/health` → 200 `ok`; `/api/info` → 200 with `{network:"Mutinynet", capabilities:{address_list, faucet, usernames, lnurl: true}, username_domain:"dev.zkcoins.app"}` (post-[#73](https://github.com/zk-coins/node/pull/73) `address_list` and `lnurl` are `false` because DEV ships the MVP-only binary identical to PRD; `faucet` and `usernames` are hardcoded `true` — mint and usernames are permanent MVP, not feature-gated; the `usernames` Cargo feature was later removed outright — see PR [#76](https://github.com/zk-coins/node/pull/76)). Bootstrap-unblock fix in PR [#36](https://github.com/zk-coins/node/pull/36) (explicit `MINTING_ADDRESS` override + global panic hook + smoke test + deploy-dev post-curl-retry; see [`MIGRATION_RESEARCH.md` §7.23](./MIGRATION_RESEARCH.md#723-minting_address-panic-in-tokiospawn-ed-task-swallows-node-bootstrap--medium-codified)). Deploy concurrency guards + PRD smoke test in PR [#51](https://github.com/zk-coins/node/pull/51). DEV/PRD parity (drop DEV-only Cargo features + remove `DEV_SKIP_BROADCAST_FAILURE` env-gate) in PR [#73](https://github.com/zk-coins/node/pull/73). **Remaining:** ① e2e roundtrip (create account → mint → send → receive) on signet from `dev.zkcoins.app`; ② R2 measurement on M3 Ultra (warm ≤ 5 s, ideal ≤ 1 s; cold ≤ 30 s; peak mem < 64 GB); ③ reactive: redesign per R2 if the budget is missed. | 2–4 d | medium | | — | Pre-mainnet blockers: D2/D10 (recipient hiding), D7 (reorg safety), D8 (per-coin nullifier-accum) | ⏳ todo | **+2–3 weeks** | high (real protocol redesign) | **MVP status:** Steps 1–8 ✅ done. Step 9 partially done — DEV is live and serving traffic; signet e2e roundtrip and the R2 performance measurement on M3 Ultra remain. **Remaining engineering effort: 0 d** for the migration itself; **remaining ops effort: ~2–4 d** for the e2e probe campaign + R2 budget check. If the R2 budget holds on first measurement, the migration is complete and the project moves to the pre-mainnet hardening track. @@ -54,11 +54,11 @@ These two requirements are not in tension — the first reduces the surface, the ### Architecture summary -The architecture is **server-side compute**: the server generates all ZK proofs; the wallet holds only the private key and signs BIP-340 Schnorr over `SHA256(serialize(asth) ‖ serialize(ocr))`. There is no in-browser Poseidon, no wasm-Plonky2 verifier, no in-app ZK gadget. +The architecture is **node-side compute**: the node generates all ZK proofs; the wallet holds only the private key and signs BIP-340 Schnorr over `SHA256(serialize(asth) ‖ serialize(ocr))`. There is no in-browser Poseidon, no wasm-Plonky2 verifier, no in-app ZK gadget. **Hardware target: Mac Studio M3 Ultra, 96 GB unified RAM, single host.** All on-box compute is available: Performance and Efficiency cores, the integrated Apple Silicon GPU (via Metal), Neural Engine, AMX. What is **not** available: external hardware accelerators (no NVIDIA, CUDA, GPU farms) and external cloud proving services (no Succinct Prover Network, no AWS GPU, no Lambda Labs). Performance budget is what the M3 Ultra delivers; if a design overshoots, the design changes — we do not add external hardware. Note: Plonky2 currently has no Metal / Apple-Silicon-GPU backend, so the integrated GPU is effectively idle for proving. That is a library property (Plonky2 ships CPU + CUDA only), not a constraint we imposed; if a Metal backend becomes available it's fair game. -zkCoins is in a **closed test environment** (DEV *and* PRD). No external users, no real money, no existing user-base to migrate. Step 7 therefore **replaces** the SP1 path outright rather than running a dual backend: SP1 modules are deleted, server starts with a clean Poseidon SMT/MMR state, no Cargo feature flag, no migration helpers. This is reflected in the lower effort estimates for step 7 (2–3 d instead of 3–5 d) and the dropped risk for R5. +zkCoins is in a **closed test environment** (DEV *and* PRD). No external users, no real money, no existing user-base to migrate. Step 7 therefore **replaces** the SP1 path outright rather than running a dual backend: SP1 modules are deleted, node starts with a clean Poseidon SMT/MMR state, no Cargo feature flag, no migration helpers. This is reflected in the lower effort estimates for step 7 (2–3 d instead of 3–5 d) and the dropped risk for R5. Pre-mainnet hardening adds another 2–3 weeks on top. @@ -74,12 +74,12 @@ exhaustive history. - [`d6a3cb9`](./../../commit/d6a3cb9) — test(account_node): 10 inline error-path tests (Account::new, get_minting_account_address Ok+Err, get_account_balance Ok+Err, load_from_file Err+missing-path, save+load roundtrip, send_coins Unknown account + Insufficient funds). Total test count 32 → 42. account_node.rs body still excluded from CI coverage gate (full SP1-era test-fixture port is a separate follow-up). state_tests.rs clippy auto-fixed in the same commit. - [`dac0179`](./../../commit/dac0179) — feat(docker): Dockerfile for the Plonky2 node (Step 9 prep). `rust:bookworm` base + rustup auto-installs nightly via `rust-toolchain`. Multi-stage build, FEATURES build-arg, debian-bookworm-slim runtime, EXPOSE 4242. Local release build verified clean (1m 26s on M3 Ultra). Smoke run end-to-end: `cargo run --release -p node` + `curl /health` → `ok`, `curl /api/info` → `{"network":"Mutinynet"}`, block scanner connects + processes Mutinynet tip. - [`c71c9fc`](./../../commit/c71c9fc) — feat(step-7): `send_coins` wired to the Plonky2 `Prover` wrapper. Off-circuit source-side validation (in-coin in source's output_coins_root + source commitment in history MMR) replaces Stage 5d-next-5 Phase 2 (deferred post-MVP, blocked on Plonky2 1.1.0 ConstantGate shape mismatch — see `MIGRATION_RESEARCH.md` §7.22 for the eventual resolution). MMR proof paths in `get_merkle_proofs` now extended to `MMR_PROOF_PATH_LEN`; history_root passed to prover is `state.mmr.root_extended(MMR_PROOF_PATH_LEN)`. Init vs AccountUpdate branch on `account.proof` + `DEV_SKIP_BROADCAST_FAILURE` env-var bypass preserved. The env-var bypass was later removed in PR [#73](https://github.com/zk-coins/node/pull/73) once DEV and PRD were unified on the MVP-only binary. Test re-enable (account_node_tests + router_tests modules disabled at include-point) is a separate follow-up. -- [`19dcecf`](./../../commit/19dcecf) — fix(ci): relax coverage scope to skip account_node.rs + server.rs during Step-7 migration (their test modules are gated off pending Stage 5d-next-5 merge); new `test_get_mmr_inclusion_proof_known_root_returns_ok` to keep state.rs at 100% line / function coverage. -- [`ee0ef4b`](./../../commit/ee0ef4b) — fix(ci+server): CI workflow rewritten for nightly toolchain + Plonky2 crate names; server clippy `-D warnings` cleanup (feature-gated structs `#[cfg(...)]`, deprecated `to_inner` → `to_keypair`, `unimplemented!` block replaced with explicit `Err` to avoid `diverging_sub_expression`); coverage timeout 30m → 60m. -- [`00adbb4`](./../../commit/00adbb4) — feat(step-7): workspace toolchain unification (stable → nightly, root absorbs `program-plonky2/` + `script-plonky2/`) + server-side import migration. `program/` + `script/` SP1 crates deleted. shared/server use the Plonky2-era modules (`hash`, `types`, `inputs`); `[u8;32]` → `HashOut` boundary conversions via `digest_from_bytes` / `digest_to_bytes`; MMR leaf hash switched from SHA256 to Poseidon `hash_concat`. `account_node::send_coins` body wrapped in `unimplemented!` pending Prover-API integration after Stage 5d-next-5 merge. 31 server tests passing (scanner, state, username, etc.); `account_node_tests` + `router_tests` modules disabled at include point. +- [`19dcecf`](./../../commit/19dcecf) — fix(ci): relax coverage scope to skip account_node.rs + router.rs during Step-7 migration (their test modules are gated off pending Stage 5d-next-5 merge); new `test_get_mmr_inclusion_proof_known_root_returns_ok` to keep state.rs at 100% line / function coverage. +- [`ee0ef4b`](./../../commit/ee0ef4b) — fix(ci+node): CI workflow rewritten for nightly toolchain + Plonky2 crate names; node clippy `-D warnings` cleanup (feature-gated structs `#[cfg(...)]`, deprecated `to_inner` → `to_keypair`, `unimplemented!` block replaced with explicit `Err` to avoid `diverging_sub_expression`); coverage timeout 30m → 60m. +- [`00adbb4`](./../../commit/00adbb4) — feat(step-7): workspace toolchain unification (stable → nightly, root absorbs `program-plonky2/` + `script-plonky2/`) + node-side import migration. `program/` + `script/` SP1 crates deleted. shared/node use the Plonky2-era modules (`hash`, `types`, `inputs`); `[u8;32]` → `HashOut` boundary conversions via `digest_from_bytes` / `digest_to_bytes`; MMR leaf hash switched from SHA256 to Poseidon `hash_concat`. `account_node::send_coins` body wrapped in `unimplemented!` pending Prover-API integration after Stage 5d-next-5 merge. 31 node tests passing (scanner, state, username, etc.); `account_node_tests` + `router_tests` modules disabled at include point. - [`b76bd39`](./../../commit/b76bd39) — feat(program-plonky2): step 7 prep — serde derives + persistence helpers (SMT/MMR/types/inputs all get `Serialize`/`Deserialize`; `save_merkle_tree` / `load_merkle_tree` / `save_mmr` / `load_mmr` ported from SP1-era helpers; 4 new tests for round-trip + missing-path I/O errors; `[u8; 33]` pubkey worked around with inline `BigArray33` helper to dodge serde's N≤32 derive limit) - [`d96bb62`](./../../commit/d96bb62) — feat(script-plonky2): step 6 — host-side prover wrapper around `StateTransitionCircuit` (new crate `script-plonky2/` with `Prover` struct + `prove_initial` / `prove_account_update` / `verify` thin wrappers; mirrors the SP1-era `script/` crate shape; nightly toolchain via rust-toolchain.toml symlink to program-plonky2) -- [`c1df545`](./../../commit/c1df545) — docs: defer Stage 5d-next-4 source-side cyclic verify to 5d-next-5 (post-MVP) — Plonky2 1.1.0's `dummy_circuit` can't reproduce `ConstantGate`-containing common_data shapes (Approach A) AND the in-circuit data-only fallback hit `goal_data != common` mismatch at build (Approach B); the trusted server folding only validly-proved commitments into history MMR makes Stage 5d-next-3 + prev_account CMP sufficient for server-heavy MVP. See MIGRATION_RESEARCH §7.21. +- [`c1df545`](./../../commit/c1df545) — docs: defer Stage 5d-next-4 source-side cyclic verify to 5d-next-5 (post-MVP) — Plonky2 1.1.0's `dummy_circuit` can't reproduce `ConstantGate`-containing common_data shapes (Approach A) AND the in-circuit data-only fallback hit `goal_data != common` mismatch at build (Approach B); the trusted node folding only validly-proved commitments into history MMR makes Stage 5d-next-3 + prev_account CMP sufficient for node-heavy MVP. See MIGRATION_RESEARCH §7.21. - [`6ea965a`](./../../commit/6ea965a) — docs: finalise session pickup — §7.20 + test-confirmation + verification checklist - [`7db536d`](./../../commit/7db536d) — docs: session-state pickup notes for next agent - [`50a1bd9`](./../../commit/50a1bd9) — test: speed up account_update panic-tests via cyclic_base_proof (~25 min wall saved per full sweep) @@ -107,7 +107,7 @@ exhaustive history. - [`401f813`](./../../commit/401f813) — docs(ROADMAP): closed test env — replace SP1, don't migrate - [`cd94f85`](./../../commit/cd94f85) — docs: CONTRIBUTING + §7 Lessons Learned (8 entries) - [`4cf98ac`](./../../commit/4cf98ac) — docs(ROADMAP): Plonky3 as post-MVP path; document rejected alternative -- [`1967087`](./../../commit/1967087) — docs(ROADMAP): server-side compute, drop wasm Poseidon +- [`1967087`](./../../commit/1967087) — docs(ROADMAP): node-side compute, drop wasm Poseidon - [`2fed8f0`](./../../commit/2fed8f0) — feat: port `ProgramInputs` + `CommitmentMerkleProofs` (4 tests) - [`9ba03bc`](./../../commit/9ba03bc) — feat: SMT non-inclusion verify gadget (3 tests + 1 negative) - [`8002ce3`](./../../commit/8002ce3) — feat: SMT inclusion gadget + `circuit/util` (4 tests) @@ -346,29 +346,29 @@ Each stage carries the 100 % line coverage gate before commit. - `cargo llvm-cov` on the prover wrapper must be 100%. **Risk:** Low. Plonky2 prover API is simpler than SP1's. -### Step 7 — Server: replace SP1 with Plonky2 (no dual backend) +### Step 7 — Node: replace SP1 with Plonky2 (no dual backend) **Effort:** 2–3 days. -**Files:** `node/src/account_node.rs`, `node/src/state.rs`, `node/src/scanner.rs`, `node/src/server.rs`. Plus delete the SP1-specific imports and replace the old `program/` and `script/` references with `program-plonky2/` + `script-plonky2/`. -**Strategy:** closed test environment means no migration. Stop the running DEV/PRD server, delete the existing SMT/MMR data files (`smt.bin`, `mmr.bin`, `accounts.bin`, `latest_block.bin`), start the new Plonky2-based server with a fresh state. No Cargo feature flag, no compatibility shim, no parallel-deploy. +**Files:** `node/src/account_node.rs`, `node/src/state.rs`, `node/src/scanner.rs`, `node/src/router.rs`. Plus delete the SP1-specific imports and replace the old `program/` and `script/` references with `program-plonky2/` + `script-plonky2/`. +**Strategy:** closed test environment means no migration. Stop the running DEV/PRD node, delete the existing SMT/MMR data files (`smt.bin`, `mmr.bin`, `accounts.bin`, `latest_block.bin`), start the new Plonky2-based node with a fresh state. No Cargo feature flag, no compatibility shim, no parallel-deploy. **Key challenge:** the Schnorr commitment message stays `SHA256(serialize(asth) ‖ serialize(ocr))` per §5.4 of `MIGRATION_RESEARCH.md`, so the scanner converts Poseidon outputs to bytes before SHA256 → BIP-340 verify. **Test plan (100% coverage gate applies):** the same `cargo llvm-cov -p node --fail-under-lines 100` gate that already enforces this on the SP1 build carries over. Every handler, every error path, every scanner state transition that lives in the PRD-feature-set must be covered. The current SP1 coverage baseline (see README.md table) is the floor to maintain. **Risk:** Low. Mechanical port, no compatibility surface area. ### Step 8 — App / wallet — ✅ done -**Status:** Pre-existing app-repo wiring already matches the new Plonky2 server contract — no code change required for the MVP. +**Status:** Pre-existing app-repo wiring already matches the new Plonky2 node contract — no code change required for the MVP. **Files in `zk-coins/app`:** - `rust/client/src/lib.rs` — `create_commitment(xpriv, num_pubkeys, asth_hex, ocr_hex)` (BIP-340 Schnorr over `SHA256(asth ‖ ocr)`, returns `{public_key, signature, message}` JSON). - `src/app/send/page.tsx` — Phase 1 (`/api/send`) + Phase 2 (`/api/commit`) two-step send flow with in-flight commit persistence + retry. - - `src/lib/api/client.ts` — typed client for every server route registered in `node/src/server.rs` (`info`, `balance`, `send`, `commit`, `mint`, `username/claim`, `username/resolve`, `address`). + - `src/lib/api/client.ts` — typed client for every API route registered in `node/src/router.rs` (`info`, `balance`, `send`, `commit`, `mint`, `username/claim`, `username/resolve`, `address`). - `src/__tests__/app/send-pipeline.test.tsx` — round-trip + retry + idempotency unit tests (mocked WASM). - - `src/__tests__/lib/api/contract.live.test.ts` — schema-conformance probes against a live server. -**Why nothing changed in the wallet for the Plonky2 cutover:** the wallet operates strictly above the server-side ZK boundary. It signs `SHA256(asth ‖ ocr)` — both 32-byte hex blobs supplied by the server — with secp256k1. Whether the server computed `asth`/`ocr` via SP1+SHA256 or Plonky2+Poseidon is opaque to the wallet, and `digest_to_bytes` on the server side already serialises Poseidon `HashOut` into the same 32-byte shape (see `program-plonky2/src/hash.rs:48`). + - `src/__tests__/lib/api/contract.live.test.ts` — schema-conformance probes against a live API. +**Why nothing changed in the wallet for the Plonky2 cutover:** the wallet operates strictly above the node-side ZK boundary. It signs `SHA256(asth ‖ ocr)` — both 32-byte hex blobs supplied by the node — with secp256k1. Whether the node computed `asth`/`ocr` via SP1+SHA256 or Plonky2+Poseidon is opaque to the wallet, and `digest_to_bytes` on the node side already serialises Poseidon `HashOut` into the same 32-byte shape (see `program-plonky2/src/hash.rs:48`). **Test gate:** existing Vitest coverage gate in `zk-coins/app` (per that repo's CONTRIBUTING.md). No new gate. -**Remaining open question for Step 9 verification:** that `signature_verifies_after_app_send` lands as an e2e probe against the live DEV server. This is part of Step 9, not Step 8. +**Remaining open question for Step 9 verification:** that `signature_verifies_after_app_send` lands as an e2e probe against the live DEV node. This is part of Step 9, not Step 8. ### Step 9 — DEV deployment + e2e — 🟡 DEV live, e2e + R2 pending **Done:** - - PR [#17](https://github.com/zk-coins/node/pull/17) merged 2026-05-18 21:50 UTC. Auto-deploy via `.github/workflows/deploy-dev.yaml` pushed `zkcoins/node:beta` to Docker Hub and deployed to the DEV host. Bootstrap fix in PR [#36](https://github.com/zk-coins/node/pull/36) (explicit `MINTING_ADDRESS` override + global panic hook + smoke test + deploy-dev post-curl-retry — see [`MIGRATION_RESEARCH.md` §7.23](./MIGRATION_RESEARCH.md#723-minting_address-panic-in-tokiospawn-ed-task-swallows-server-bootstrap--medium-codified)). + - PR [#17](https://github.com/zk-coins/node/pull/17) merged 2026-05-18 21:50 UTC. Auto-deploy via `.github/workflows/deploy-dev.yaml` pushed `zkcoins/node:beta` to Docker Hub and deployed to the DEV host. Bootstrap fix in PR [#36](https://github.com/zk-coins/node/pull/36) (explicit `MINTING_ADDRESS` override + global panic hook + smoke test + deploy-dev post-curl-retry — see [`MIGRATION_RESEARCH.md` §7.23](./MIGRATION_RESEARCH.md#723-minting_address-panic-in-tokiospawn-ed-task-swallows-node-bootstrap--medium-codified)). - `https://dev-api.zkcoins.app/health` → 200 `ok`; `https://dev-api.zkcoins.app/api/info` → 200 with `{network:"Mutinynet", capabilities:{address_list, faucet, usernames, lnurl: true}, username_domain:"dev.zkcoins.app"}` (post-[#73](https://github.com/zk-coins/node/pull/73) `address_list` and `lnurl` are `false` because DEV ships the MVP-only binary identical to PRD; `faucet` and `usernames` are hardcoded `true` — mint and usernames are permanent MVP, not feature-gated; the `usernames` Cargo feature was later removed outright — see PR [#76](https://github.com/zk-coins/node/pull/76)). - Deploy hardening: PR [#51](https://github.com/zk-coins/node/pull/51) added deploy-dev + deploy-prd concurrency guards and a PRD smoke test. - DEV/PRD parity: PR [#73](https://github.com/zk-coins/node/pull/73) dropped the DEV-only Cargo features (`address-list`, `faucet`, `usernames`, `lnurl`) and removed the `DEV_SKIP_BROADCAST_FAILURE` env-gate so the two environments run the identical MVP-only binary. A follow-up refactor removed the `faucet` Cargo feature outright — mint is permanent MVP and ships unconditionally in every build — and a further refactor removed the `usernames` Cargo feature so usernames are permanent MVP and ship unconditionally too (PR [#76](https://github.com/zk-coins/node/pull/76)). @@ -418,10 +418,10 @@ Plonky2 is bridge technology. Post-MVP (after step 9): Plonky3 evaluation. Field (c) switch to a folding scheme (Nova / HyperNova / similar) that's CPU-native; (d) opportunistic: if a Plonky2 Metal backend becomes available, evaluate. **Explicitly OFF the table:** discrete NVIDIA / CUDA hardware (we have an Apple Silicon box, not an x86 + NVIDIA host), Succinct Prover Network (violates closed-test-env + no-external-services rule), Apple Neural Engine / AMX as custom-kernel targets (we won't author the kernels ourselves). -**Trigger to escalate:** measured proof time > 5 s on M3 Ultra. Wallet-side performance is N/A — proving is server-side; the wallet's send-flow latency = proof time + network roundtrip. +**Trigger to escalate:** measured proof time > 5 s on M3 Ultra. Wallet-side performance is N/A — proving is node-side; the wallet's send-flow latency = proof time + network roundtrip. ### R3 — (removed) -Was: "Wasm Poseidon too slow." No longer applicable — the wallet performs no Poseidon hashing (server-side compute architecture). The wallet's only crypto is BIP-340 Schnorr signing of a SHA256 digest, which WebCrypto handles natively. +Was: "Wasm Poseidon too slow." No longer applicable — the wallet performs no Poseidon hashing (node-side compute architecture). The wallet's only crypto is BIP-340 Schnorr signing of a SHA256 digest, which WebCrypto handles natively. ### R4 — Pre-mainnet hardening pushes timeline (high) **What can go wrong:** D2/D10 hiding recipient is a real protocol change, not a patch. May require re-doing step 5 if it doesn't fit the existing circuit shape. diff --git a/SPEC.md b/SPEC.md index 800b2f75..ed555da9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -335,7 +335,7 @@ fn main(inputs: ProgramInputs): ### Note on the minting account -`MINTING_ADDRESS` is a `HashDigest` constant. In the Plonky2/Poseidon build it is a domain-separated placeholder baked into `program-plonky2/src/types.rs::MINTING_ADDRESS` and **overridden at runtime** in `runtime.rs::start_rest_node`: after constructing the minting `ClientAccount` from `minting_secret.bin`, the code sets `minting_client.address = *MINTING_ADDRESS` so the prover circuit and the server state share the same value. This runtime override was added in PR [#36](https://github.com/zk-coins/node/pull/36) to fix a panic-in-tokio-spawn regression (see [`MIGRATION_RESEARCH.md` §7.23](./MIGRATION_RESEARCH.md#723-minting_address-panic-in-tokiospawn-ed-task-swallows-server-bootstrap--medium-codified)). The closed test environment means we are not bound to the historical SP1 minting key. +`MINTING_ADDRESS` is a `HashDigest` constant. In the Plonky2/Poseidon build it is a domain-separated placeholder baked into `program-plonky2/src/types.rs::MINTING_ADDRESS` and **overridden at runtime** in `runtime.rs::start_rest_node`: after constructing the minting `ClientAccount` from `minting_secret.bin`, the code sets `minting_client.address = *MINTING_ADDRESS` so the prover circuit and the node state share the same value. This runtime override was added in PR [#36](https://github.com/zk-coins/node/pull/36) to fix a panic-in-tokio-spawn regression (see [`MIGRATION_RESEARCH.md` §7.23](./MIGRATION_RESEARCH.md#723-minting_address-panic-in-tokiospawn-ed-task-swallows-node-bootstrap--medium-codified)). The closed test environment means we are not bound to the historical SP1 minting key. --- @@ -385,10 +385,10 @@ For the **initial proof** there is no prior account proof to verify. The circuit ### 11.2 Client (`shared::ClientAccount::create_commitment`) -Given a fresh server response `(proof_id, account_state_hash, output_coins_root)`: +Given a fresh node response `(proof_id, account_state_hash, output_coins_root)`: 1. Sign `H(account_state_hash || output_coins_root)` with the **current** commitment private key (BIP-32 derivation index = `num_pubkeys - 1` in the reference). -2. POST `(proof_id, commitment)` to `/api/commit`. The server attaches this commitment to the proof, builds a Taproot commit+reveal tx pair whose commit-tx txid begins with `4242`, and broadcasts. +2. POST `(proof_id, commitment)` to `/api/commit`. The node attaches this commitment to the proof, builds a Taproot commit+reveal tx pair whose commit-tx txid begins with `4242`, and broadcasts. ### 11.3 Scanner (`node::scanner`) @@ -409,7 +409,7 @@ This list captures the non-trivial decisions a port must make. None of them are 1. **Pick `H`.** Recommended: Poseidon over Goldilocks (`F = GF(2^64 - 2^32 + 1)`), width 12, full+partial rounds per the standard parameter set. `HashDigest` becomes 4 field elements (≡ 256-bit security with appropriate rate). -2. **Re-derive `MINTING_ADDRESS`.** Plonky2 port has it as a domain-separated placeholder (`program-plonky2/src/types.rs::MINTING_ADDRESS`). At server runtime, `runtime.rs::start_rest_node` overrides it by setting `minting_client.address = *MINTING_ADDRESS` on the freshly-constructed `ClientAccount` so the prover circuit and runtime state agree on the value (see `MIGRATION_RESEARCH.md` §7.23). Closed test environment means no requirement to match the historical SP1 minting key. +2. **Re-derive `MINTING_ADDRESS`.** Plonky2 port has it as a domain-separated placeholder (`program-plonky2/src/types.rs::MINTING_ADDRESS`). At node runtime, `runtime.rs::start_rest_node` overrides it by setting `minting_client.address = *MINTING_ADDRESS` on the freshly-constructed `ClientAccount` so the prover circuit and runtime state agree on the value (see `MIGRATION_RESEARCH.md` §7.23). Closed test environment means no requirement to match the historical SP1 minting key. 3. **`AccountState` hashing.** Drop `bincode + SHA256`. Define a canonical field-element layout (e.g. `[owner_limbs(4), balance_lo, balance_hi, pubkey_x_limbs(4), pubkey_parity]`) and hash with Poseidon. Both circuit and host MUST agree. From bf75aecc81195add1c3314e24ca5c98c1038e0e5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 22:33:18 +0200 Subject: [PATCH 05/19] feat(db): persist inscription kind (mint/send) + expose via /api/inscriptions/:txid (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(db): persist inscription kind (mint vs send) + expose via /api/inscriptions/:txid Adds the `kind` column to `pending_inscriptions` so the DB alone tells you what a row represents — previously the table only persisted the publisher's commit/reveal crash-recovery state, and disambiguating mint vs user-send required either grepping container logs (`Sending commitment data` vs. `Broadcasting user commitment`) or deserializing the bincode `Commitment` blob and re-deriving the minting account's current pubkey index. Closed test environment (see CONTRIBUTING.md and the `feedback_zkcoins_closed_test_env` invariant): migration 0006 wipes existing `pending_inscriptions` rows before adding the NOT NULL column. Those rows are crash-recovery state, expected to be empty on a healthy server. Threaded `InscriptionKind` through `insert_pending_inscription` and `create_and_broadcast_inscription`; the two callers tag explicitly: * `router::mint_handler` → `InscriptionKind::Mint` * `runtime::broadcast_commit_and_deliver` → `InscriptionKind::Send` New endpoint `GET /api/inscriptions/:txid` returns the `(kind, status, commit_output_value, timestamps)` tuple for a given commit txid (display order, like every block explorer). Surfaces the DB row's semantics to operators without re-exposing the raw commitment/commit_tx/reveal_tx blobs. Tests updated to pass the new `kind` parameter; root response and test stubs follow. * Merge pull request #116 from zk-coins/feat/request-audit-log feat(audit): persist every HTTP request and response in request_log * feat(db): persist every node input and state transition (full database trail) (#118) Schema + helpers + critical-path wiring for the remaining persistence gaps. After this commit the DB answers every operator-forensic question without falling back to container logs: "what kind of operation was this?" (#113), "every HTTP request body the node received?" (#116), and now: "what did the publisher attempt against Esplora?", "which blocks did the scanner process?", "which inscriptions did it observe (own vs. external)?", "what did each account look like before this change?", "who tried to claim a name and were they refused?", "how long did the reveal-txid mining take?", "what happened during startup?". Closed test env (`feedback_zkcoins_closed_test_env`) + the server-is- not-a-privacy-boundary stance (`feedback_zkcoins_no_privacy_promise`) mean the new columns store everything cleartext — sender/recipient, amounts, signatures, raw commitments. Schema (migration 0008) ----------------------- * `pending_inscriptions.failure_reason TEXT` + `reveal_txid BYTEA` * `esplora_log` — outbound HTTP / WS calls against Esplora * `error_log` — application-level errors, structured, FK back to `request_log` * `block_log` — every processed Bitcoin block * `observed_inscriptions` — every commitment the scanner extracted, tagged own / external * `state_update_log` — every SMT/MMR transition with prev/new roots * `account_history` — every accounts row change, with old + new blob, populated by an `AFTER INSERT OR UPDATE` trigger so coverage is 100% regardless of caller * `username_claim_log` — every claim attempt, success or reject * `tx_mining_log` — reveal-txid prefix-mining stats * `coin_proof_store` — durable mirror of the in-memory `ProofStore` (schema only in this PR) * `boot_log` — startup / shutdown / migration events Wired in this PR ---------------- * `insert_pending_inscription` writes the explicit `reveal_txid`; `/api/inscriptions/:txid` response now includes `reveal_txid` and `failure_reason`. * `create_and_broadcast_inscription` populates `failure_reason` when the broadcast errors out, and persists a `tx_mining_log` row for every reveal-txid mining run (target prefix, nonces tried, duration, final nonce + txid). * `claim_username_handler` records every claim outcome (success + precheck reject + SQL race-loser) in `username_claim_log`. Pure- validation rejects (bad hex / bad signature format) are already captured via `request_log` from PR #116. * `scan_for_inscriptions` takes a `Option` and appends one `block_log` row per processed block (block_hash, height, inscription count, processing duration). * Scanner callback in `main.rs` writes `observed_inscriptions` for every commitment it deserialises — tagged `own` when a matching `pending_inscriptions` row exists, `external` otherwise. * `runtime::start_rest_node` emits a `boot_log` startup event with version, network, listen addr, pid as JSONB metadata. * `account_history` is filled automatically by a PL/pgSQL trigger on `accounts` (INSERT OR UPDATE) — 100% coverage of every existing and future caller, including manual psql edits. Callers with semantic context (`mint`, `send`, `receive`, `recovery`) can override the default `source = 'scanner'` via per-transaction GUC (`SET LOCAL zkcoins.account_source = 'mint'`). Schema + helpers ready, full wiring deferred -------------------------------------------- * `esplora_log` — helper present, instrumentation of the individual esplora-client call-sites (UTXO lookups, broadcast, get_tx, block data) deferred. Many touch points, mechanical. * `error_log` — helper present, replacement of the existing `eprintln!` paths with a `log_err!` macro deferred. ~50 sites across publisher / scanner / runtime / router. * `state_update_log` — helper present, instrumentation of the two state.update sites (mint_handler Phase-E + scanner callback) deferred so this PR's diff stays reviewable. * `coin_proof_store` — schema only; persisting the in-memory ProofStore is a behaviour change that warrants its own PR. CI parity verified locally -------------------------- * `cargo fmt --all --check` ✓ * `cargo clippy -p node -p shared -- -D warnings` ✓ * `cargo clippy -p node --all-features -- -D warnings` ✓ * `cargo check -p node --tests` ✓ * refactor(db): schema polish — fix semantic gaps in the full-trail stack (#119) * refactor(db): schema polish — fix semantic gaps in the full-trail stack Closes the consistency gaps surfaced by the post-#118 review: 1. `pending_inscriptions`: when a broadcast errors out, the row is now advanced to `status = 'failed'` AND `failure_reason` is set atomically (was: only failure_reason, status stayed at the last in-progress state). The CHECK-allowed 'failed' is no longer dead code; the discriminator pairs with the error chain. `update_pending_failure_reason` → `mark_pending_failed`. 2. `observed_inscriptions.integrated` is now actually flipped: after the scanner's `state.update` + atomic `persist_state_tx` land the commitment in SMT/MMR, the matching observed row is updated to `integrated = true, integrated_at = NOW()`. Idempotent via `WHERE integrated = FALSE`. Was: column always false. 3. `account_history_capture()` trigger now reads an optional `zkcoins.request_log_id` GUC. Callers with an HTTP request context can `SET LOCAL` it before the upsert, threading the link to `request_log` through without reimplementing the upsert in application code. The column existed but had no writer; this PR's trigger fills it when the caller provides context. 4. `request_log.client_ip` — new column populated by the audit middleware from `CF-Connecting-IP` (Cloudflare Tunnel is the only ingress on zkcoins-node), falling back to the first segment of `X-Forwarded-For`, then `remote_addr`. `remote_addr` stays as the literal TCP peer (always 127.0.0.1 behind cloudflared) for transport-level forensics. 5. `state_update_log.trigger` → `trigger_source`. Pure rename; no code wired yet so the migration is free. `trigger` collided with Postgres trigger vocabulary at every read. 6. `block_log` consolidated to a single timestamp: drop `received_at` (which was set to NOW() in the same INSERT as `processed_at` — dead weight), keep `processed_at NOT NULL DEFAULT NOW()` as the canonical "scanner saw + processed this block" timestamp. Separate WS-frame-receive logging is a future event-stream feature. 7. `pending_inscriptions.reveal_txid` → `NOT NULL`. Every code path since #118 fills it; the column had been nullable defensively against pre-existing rows, but migration 0006 wiped those, so the defensive nullability buys nothing. Migration deletes any in-flight `reveal_txid IS NULL` rows first (closed test env — see `feedback_zkcoins_migrations_may_wipe`). 8. `esplora_log.triggering_request_log_id` — new column analogous to `error_log.request_log_id`. Outbound Esplora chatter caused by an inbound HTTP request can now be joined back to its `request_log` row. Wiring is follow-up; the column is in place. Verified locally ---------------- * `cargo fmt --all --check` ✓ * `cargo clippy -p node -p shared -- -D warnings` ✓ * `cargo clippy -p node --all-features -- -D warnings` ✓ * `cargo check -p node --tests` ✓ * refactor(db): schema polish round 2 — type safety, FKs, logical checks (#120) Closes the remaining gaps from the round-2 review of the persistence stack. Pure tightening — no new semantics, only stronger constraints and a few cosmetic name cleanups. Type safety ----------- * Length CHECKs on every BYTEA column with a domain-fixed size: txid (32 B) on 7 tables, address (32 B Poseidon) on 5 tables, root hashes (32 B) on mmr_root_index + state_update_log, public_key (33 B compressed secp256k1) on observed_inscriptions, signature (64 B Schnorr BIP-340) on username_claim_log. * `block_log.block_height` nullable instead of sentinel `-1`. Code side now passes `Option` straight through. * `pending_inscriptions.reveal_txid` UNIQUE (was: only commit_txid). A duplicate reveal_txid is on-chain impossible; UNIQUE makes that an insert-time error instead of an undetected divergence. * The pre-existing partial index `pending_inscriptions_reveal_txid_idx WHERE reveal_txid IS NOT NULL` is dropped — column is NOT NULL since 0009, and the new UNIQUE constraint already builds the required B-Tree. * `tx_mining_log.commit_txid` NOT NULL + length CHECK + FK to `pending_inscriptions(commit_txid)`. The publisher path always sets it; the nullable + FK-less shape was schema drift. Foreign keys ------------ * `tx_mining_log.commit_txid` → `pending_inscriptions.commit_txid` ON DELETE CASCADE (publisher created both rows; lifetime is coupled). * `coin_proof_store.consumed_by_commit_txid` → `pending_inscriptions.commit_txid` ON DELETE SET NULL (proof can outlive the inscription it eventually fed). Logical-pair CHECKs ------------------- Mutually-exclusive flag/timestamp pairs are now enforced by the DB: * `observed_inscriptions`: `integrated` ⇔ `integrated_at IS NOT NULL` * `username_claim_log`: `success` ⇔ `reject_reason IS NULL` * `coin_proof_store`: `consumed_at` ⇔ `consumed_by_commit_txid` * `pending_inscriptions`: `status = 'failed'` ⇒ `failure_reason IS NOT NULL` (the reverse direction is allowed — retried rows may carry a stale reason in a non-failed status, harmless). Vocabulary alignment -------------------- * `esplora_log.triggered_by` → `trigger_source` and CHECK-constrained with the same vocabulary as `state_update_log.trigger_source` (`'mint','send','scanner','recovery','health','resume'`). One concept, one name, one enum. Existing rows with values outside the vocabulary are wiped first (closed test env). * `accounts.created_at`, `latest_block.created_at`, `smt_state.created_at`, `mmr_state.created_at` added — these four pre-existing tables only had `updated_at`, breaking the convention that every domain table tracks both ends of the lifetime. Performance indices ------------------- * `account_history (triggering_commit_txid, changed_at DESC)` partial WHERE NOT NULL — for "show all account changes triggered by inscription X". * `pending_inscriptions (kind, created_at DESC)` — for "all mints in the last hour" style queries. * `pending_inscriptions (updated_at DESC) WHERE status = 'failed'` — for "show all failed Sends". Enum CHECKs on free-text columns -------------------------------- * `boot_log.event_type IN ('startup','shutdown','migration', 'state_load','vault_sync')`. * `tx_mining_log.target_prefix ~ '^[0-9a-f]+$'` — lowercase hex shape, keeps the column flexible (the marker may change) while catching typo regressions. Cosmetic -------- * `account_history_capture()` → `accounts_history_capture()` (matches the table noun + trigger name). DROP + CREATE because Postgres trigger functions can't be renamed in place when the trigger references them. * `mmr_root_index.leaf_index` UNIQUE — `mmr.leaf_count()` is monotonic, a duplicate is a code bug. UNIQUE turns it into a constraint violation at insert time. Per `feedback_zkcoins_migrations_may_wipe`: rows that would block a new CHECK / NOT NULL are wiped first (closed test env). Verified locally ---------------- * `cargo fmt --all --check` ✓ * `cargo clippy -p node -p shared -- -D warnings` ✓ * `cargo clippy -p node --all-features -- -D warnings` ✓ * `cargo check -p node --tests` ✓ --- Cargo.lock | 4 + node/Cargo.toml | 8 + node/migrations/0006_inscription_kind.sql | 18 + node/migrations/0007_request_log.sql | 57 ++ node/migrations/0008_full_database_trail.sql | 358 +++++++++++ node/migrations/0009_schema_polish.sql | 124 ++++ node/migrations/0010_schema_polish_round2.sql | 273 ++++++++ node/src/audit.rs | 170 +++++ node/src/db.rs | 595 +++++++++++++++++- node/src/db_tests.rs | 9 + node/src/lib.rs | 1 + node/src/main.rs | 68 +- node/src/publisher.rs | 82 ++- node/src/publisher_tests.rs | 96 ++- node/src/router.rs | 102 ++- node/src/runtime.rs | 48 +- node/src/scanner_runtime.rs | 34 +- 17 files changed, 1990 insertions(+), 57 deletions(-) create mode 100644 node/migrations/0006_inscription_kind.sql create mode 100644 node/migrations/0007_request_log.sql create mode 100644 node/migrations/0008_full_database_trail.sql create mode 100644 node/migrations/0009_schema_polish.sql create mode 100644 node/migrations/0010_schema_polish_round2.sql create mode 100644 node/src/audit.rs diff --git a/Cargo.lock b/Cargo.lock index f170096f..cda8f445 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3263,7 +3263,9 @@ dependencies = [ "serde_json", "sha2", "sqlx-core", + "sqlx-mysql", "sqlx-postgres", + "sqlx-sqlite", "syn 2.0.117", "tokio", "url", @@ -3300,6 +3302,7 @@ dependencies = [ "percent-encoding", "rand 0.8.6", "rsa", + "serde", "sha1", "sha2", "smallvec", @@ -3363,6 +3366,7 @@ dependencies = [ "libsqlite3-sys", "log", "percent-encoding", + "serde", "serde_urlencoded", "sqlx-core", "thiserror 2.0.18", diff --git a/node/Cargo.toml b/node/Cargo.toml index 3b581a05..de3a2dc9 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -38,6 +38,10 @@ serde_json = "1.0" bitcoincore-zmq = { version = "=1.5.4", optional = true } esplora-client = { git = "https://github.com/BitVM/rust-esplora-client", branch = "master" } axum = { version = "0.7.9", features = ["json", "multipart"] } +# `BodyExt::collect()` for the audit middleware's request/response +# body buffering. Promoted from `[dev-dependencies]` because the +# middleware now lives in the production binary. +http-body-util = "0.1" anyhow = "1.0" zkcoins-prover = { path = "../script-plonky2/", package = "zkcoins-prover-plonky2" } zkcoins-program = { path = "../program-plonky2/", package = "zkcoins-program-plonky2" } @@ -53,6 +57,10 @@ sqlx = { version = "0.8", default-features = false, features = [ "postgres", "macros", "migrate", + # `json` lets the audit middleware bind `serde_json::Value` directly + # to the `request_headers` / `response_headers` JSONB columns without + # a manual `Encode` shim. + "json", ] } [dev-dependencies] diff --git a/node/migrations/0006_inscription_kind.sql b/node/migrations/0006_inscription_kind.sql new file mode 100644 index 00000000..637a6ebb --- /dev/null +++ b/node/migrations/0006_inscription_kind.sql @@ -0,0 +1,18 @@ +-- Distinguish mint inscriptions from user-send (`/api/commit`) inscriptions +-- so the DB alone answers "what kind of operation was this?" without +-- having to grep container logs or re-derive the minting account's +-- pubkey-at-index-N. The previous schema persisted the bincode +-- `Commitment` blob only — semantically opaque without state context. +-- +-- Closed test environment (see CONTRIBUTING.md and the project memo +-- `feedback_zkcoins_closed_test_env`): existing rows are crash-recovery +-- state for the publisher's commit/reveal pair, expected to be +-- `complete` and empty on a healthy server. Wiping them is the +-- documented "alt raus, neu rein" pattern — no backfill, no transition +-- shim. + +DELETE FROM pending_inscriptions; + +ALTER TABLE pending_inscriptions + ADD COLUMN kind TEXT NOT NULL + CHECK (kind IN ('mint', 'send')); diff --git a/node/migrations/0007_request_log.sql b/node/migrations/0007_request_log.sql new file mode 100644 index 00000000..bd74b35b --- /dev/null +++ b/node/migrations/0007_request_log.sql @@ -0,0 +1,57 @@ +-- Full HTTP audit log: every request the node accepts is persisted +-- with its raw body, headers, and the bytes of the response that was +-- sent back. The server is not a privacy boundary — anyone who wants +-- shielded operation runs their own node; the operator-side +-- observation surface is fair game. +-- +-- Storage notes +-- ------------- +-- * `request_body` / `response_body` are BYTEA, NOT TEXT — request +-- payloads may be binary (multipart, msgpack-shaped frames, etc.) +-- and storing as text would force a charset round-trip we don't +-- want. Today every route is JSON, but the column type is the +-- conservative pick. +-- * `request_headers` / `response_headers` are JSONB to make the +-- ad-hoc forensics queries (`WHERE request_headers ->> 'user-agent' +-- LIKE '%wallet%'`) reasonable without a separate schema. +-- * `query` is the raw URL query string (post-`?`), nullable for +-- requests without one. +-- * `remote_addr` is the peer address axum's `ConnectInfo` +-- resolves to — i.e. the immediate TCP peer. Behind a reverse +-- proxy this is the proxy address; the actual client IP needs an +-- `X-Forwarded-For` header which is already captured in +-- `request_headers`. +-- * `duration_us` is wall-clock microseconds from the moment the +-- middleware first sees the request to the moment it forwards the +-- buffered response — a low-cost service-level latency metric that +-- does not need a separate Prometheus pipeline to be useful. +-- +-- Indices +-- ------- +-- * `request_log_received_at_idx` (DESC) for the bulk "what landed on +-- the last 24 h" queries and for retention pruning. +-- * `request_log_path_idx` for per-endpoint forensics +-- (`SELECT … WHERE path = '/api/mint' ORDER BY received_at DESC`). +-- +-- Retention is deliberately not enforced by this migration. The +-- operator can decide what to keep — pruning is one `DELETE FROM +-- request_log WHERE received_at < NOW() - INTERVAL '30 days'` away. + +CREATE TABLE request_log ( + id BIGSERIAL PRIMARY KEY, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + method TEXT NOT NULL, + path TEXT NOT NULL, + query TEXT, + remote_addr TEXT, + user_agent TEXT, + request_headers JSONB NOT NULL, + request_body BYTEA NOT NULL, + response_status SMALLINT NOT NULL, + response_headers JSONB NOT NULL, + response_body BYTEA NOT NULL, + duration_us BIGINT NOT NULL +); + +CREATE INDEX request_log_received_at_idx ON request_log (received_at DESC); +CREATE INDEX request_log_path_idx ON request_log (path, received_at DESC); diff --git a/node/migrations/0008_full_database_trail.sql b/node/migrations/0008_full_database_trail.sql new file mode 100644 index 00000000..7b979cc9 --- /dev/null +++ b/node/migrations/0008_full_database_trail.sql @@ -0,0 +1,358 @@ +-- Full database trail: every input the node receives and every state +-- transition the node performs lands in a queryable table. +-- +-- Rationale: the request_log (0007) covers the HTTP layer, but the +-- node also (a) talks outbound to Esplora, (b) ingests blocks from a +-- WebSocket stream, (c) extracts inscriptions from on-chain witnesses +-- including external ones, (d) advances SMT/MMR state, and (e) errors +-- in places that today only show up in container stdout. None of those +-- live in a queryable shape. This migration adds the missing tables. +-- +-- Closed test env (`feedback_zkcoins_closed_test_env`): no backward- +-- compat shims, no retention enforcement here — the operator prunes +-- with simple `DELETE WHERE event_at < NOW() - INTERVAL '…'`. + +-- =========================================================================== +-- 0. pending_inscriptions: add failure_reason + reveal_txid +-- =========================================================================== +-- +-- failure_reason: today the `failed` status exists in the CHECK +-- constraint but no column captures *why*. The publisher's +-- `create_and_broadcast_inscription` error path now fills this with the +-- chain of Esplora / network errors that triggered the failure, so the +-- operator can answer "why didn't this Send land?" from a single SQL. +-- +-- reveal_txid: today only the raw `reveal_tx` blob is persisted; the +-- txid has to be re-derived by deserialising 350+ bytes and running +-- `compute_txid()`. Storing it explicitly lets queries reference the +-- reveal directly and feeds the `/api/inscriptions/:txid` response. + +ALTER TABLE pending_inscriptions + ADD COLUMN failure_reason TEXT, + ADD COLUMN reveal_txid BYTEA; + +CREATE INDEX pending_inscriptions_reveal_txid_idx + ON pending_inscriptions (reveal_txid) + WHERE reveal_txid IS NOT NULL; + +-- =========================================================================== +-- 1. esplora_log: every outbound call against the Esplora REST / WS API +-- =========================================================================== +-- +-- The publisher and scanner are the two consumers of Esplora; their +-- success or failure determines whether mints / sends land and whether +-- state advances. Today a 503 from Esplora's POST /tx surfaces only as +-- an `eprintln!` line; this table captures the full request/response +-- pair so the operator can correlate publisher failures with upstream +-- outages by `WHERE response_status >= 400`. +-- +-- `direction` enumerates the three legs we care about — outbound HTTP +-- (REST), outbound WebSocket subscribe / poll frames, and inbound +-- WebSocket frames (block events). The CHECK pins the vocabulary so +-- typos surface as constraint violations. + +CREATE TABLE esplora_log ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + direction TEXT NOT NULL + CHECK (direction IN ('outbound_http', 'outbound_ws', 'inbound_ws')), + method TEXT, + url TEXT NOT NULL, + request_body BYTEA, + response_status SMALLINT, + response_body BYTEA, + duration_us BIGINT, + triggered_by TEXT +); + +CREATE INDEX esplora_log_occurred_at_idx ON esplora_log (occurred_at DESC); +CREATE INDEX esplora_log_response_idx ON esplora_log (response_status, occurred_at DESC) + WHERE response_status IS NOT NULL; + +-- =========================================================================== +-- 2. error_log: application errors, structured +-- =========================================================================== +-- +-- Today every error path uses `eprintln!`, which puts the message into +-- the container's JSON log driver (capped at 100 MB × 3 files) and +-- nowhere else. After 300 MB of normal logs the original error is +-- gone. This table persists errors at write time with the source +-- module, severity, and a serialisable error chain so post-mortem +-- queries are SQL-shaped. +-- +-- `request_log_id` is the optional FK back to `request_log` for +-- errors that surface inside an HTTP handler — the audit middleware +-- already wrote that row before the handler ran, so the FK is safe. + +CREATE TABLE error_log ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + severity TEXT NOT NULL CHECK (severity IN ('warn', 'error', 'fatal')), + source TEXT NOT NULL, + message TEXT NOT NULL, + error_chain TEXT, + request_log_id BIGINT REFERENCES request_log (id) ON DELETE SET NULL +); + +CREATE INDEX error_log_occurred_at_idx ON error_log (occurred_at DESC); +CREATE INDEX error_log_severity_idx ON error_log (severity, occurred_at DESC); + +-- =========================================================================== +-- 3. block_log: every Bitcoin block the scanner processed +-- =========================================================================== +-- +-- The scanner is event-driven (WS); today the only persisted artefact +-- of a processed block is the `latest_block` singleton. There is no +-- history — "did we process block X?" needs to be answered from +-- container logs. This table adds an append-only history of every +-- processed block plus a count of inscriptions extracted from it, +-- which speeds incident triage to a `WHERE block_height BETWEEN ? +-- AND ?` query. + +CREATE TABLE block_log ( + id BIGSERIAL PRIMARY KEY, + block_hash BYTEA NOT NULL UNIQUE, + block_height BIGINT NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + inscription_count INTEGER NOT NULL DEFAULT 0, + processing_duration_us BIGINT +); + +CREATE INDEX block_log_received_at_idx ON block_log (received_at DESC); +CREATE INDEX block_log_height_idx ON block_log (block_height DESC); + +-- =========================================================================== +-- 4. observed_inscriptions: every inscription the scanner ever saw +-- =========================================================================== +-- +-- `pending_inscriptions` only tracks the publisher's own outgoing +-- inscriptions. External inscriptions — mints originating from another +-- operator's node, manual recoveries via `recover_inscription` CLI, +-- replays from a re-sync — currently mutate `accounts` / `mmr_root_index` +-- but leave no audit row of the detection event itself. This table +-- closes that gap with a row per extracted commitment, tagged +-- `own` or `external`. +-- +-- `public_key` is the 33-byte secp256k1 compressed pubkey lifted from +-- the bincode-deserialised commitment. Surfacing it as a column avoids +-- the bincode round-trip every time the operator wants to filter by +-- account, and makes joins to `accounts` (via SHA256(public_key)) one +-- index step away. + +CREATE TABLE observed_inscriptions ( + id BIGSERIAL PRIMARY KEY, + commit_txid BYTEA NOT NULL UNIQUE, + block_hash BYTEA, + block_height BIGINT, + observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + source TEXT NOT NULL CHECK (source IN ('own', 'external')), + commitment BYTEA NOT NULL, + public_key BYTEA NOT NULL, + integrated BOOLEAN NOT NULL DEFAULT FALSE, + integrated_at TIMESTAMPTZ +); + +CREATE INDEX observed_inscriptions_block_height_idx ON observed_inscriptions (block_height DESC); +CREATE INDEX observed_inscriptions_source_idx ON observed_inscriptions (source, observed_at DESC); +CREATE INDEX observed_inscriptions_public_key_idx ON observed_inscriptions (public_key); + +-- =========================================================================== +-- 5. state_update_log: every State::update transition +-- =========================================================================== +-- +-- The MMR/SMT roots advance on every accepted commitment. Today the +-- `mmr_root_index` table holds the (prev_mmr_root, smt_root, leaf_index) +-- triple but not the trigger (mint vs. scanner-replay vs. recovery), +-- the SMT root *before* the update, or the link back to the inscription +-- that caused the transition. This table records the full transition so +-- a state divergence can be traced to the originating event. + +CREATE TABLE state_update_log ( + id BIGSERIAL PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + trigger TEXT NOT NULL + CHECK (trigger IN ('mint', 'send', 'scanner_replay', 'recovery')), + commit_txid BYTEA, + prev_mmr_root BYTEA NOT NULL, + new_mmr_root BYTEA NOT NULL, + smt_root_before BYTEA NOT NULL, + smt_root_after BYTEA NOT NULL, + commitment_count INTEGER NOT NULL DEFAULT 1 +); + +CREATE INDEX state_update_log_applied_at_idx ON state_update_log (applied_at DESC); +CREATE INDEX state_update_log_commit_txid_idx ON state_update_log (commit_txid) + WHERE commit_txid IS NOT NULL; + +-- =========================================================================== +-- 6. account_history: every change to an account's serialised state +-- =========================================================================== +-- +-- `accounts` is overwrite-on-upsert: the row reflects the current +-- state, the previous balance / coin set is gone the moment the next +-- mint or receive lands. This table appends a row for every change +-- with the previous and new blob, so a "show me everything that ever +-- happened to address X" query is one index lookup away. +-- +-- The `triggering_*` columns thread the cause back to its origin — +-- the HTTP request that started the mutation or the commit txid that +-- the scanner ingested. + +CREATE TABLE account_history ( + id BIGSERIAL PRIMARY KEY, + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + address BYTEA NOT NULL, + prev_data BYTEA, + new_data BYTEA NOT NULL, + source TEXT NOT NULL + CHECK (source IN ('mint', 'send', 'receive', 'scanner', 'recovery')), + triggering_commit_txid BYTEA, + triggering_request_log_id BIGINT REFERENCES request_log (id) ON DELETE SET NULL +); + +CREATE INDEX account_history_address_idx ON account_history (address, changed_at DESC); +CREATE INDEX account_history_changed_at_idx ON account_history (changed_at DESC); + +-- Postgres trigger: capture every accounts INSERT / UPDATE as an +-- account_history row. Without code-level wiring this gives 100% +-- coverage — any caller (current, future, manual psql, recovery CLI) +-- contributes a history row automatically. +-- +-- `source` defaults to 'scanner' because that's the dominant upsert +-- path. Callers that know better (`mint_handler`, `runtime:: +-- broadcast_commit_and_deliver`) can override via a per-transaction +-- GUC before the upsert: +-- +-- SET LOCAL zkcoins.account_source = 'mint'; +-- SET LOCAL zkcoins.account_commit_txid = '\x...'; -- hex bytea +-- +-- The trigger reads those via `current_setting(..., true)` (the second +-- arg = missing_ok); unset GUCs fall back to the documented defaults. +CREATE OR REPLACE FUNCTION account_history_capture() RETURNS TRIGGER AS $$ +DECLARE + src TEXT := COALESCE(NULLIF(current_setting('zkcoins.account_source', TRUE), ''), 'scanner'); + commit_txid_hex TEXT := NULLIF(current_setting('zkcoins.account_commit_txid', TRUE), ''); + commit_txid_bytes BYTEA := NULL; +BEGIN + -- Skip when row content didn't change (UPDATEs that touch only + -- `updated_at` should not generate history noise). + IF TG_OP = 'UPDATE' AND OLD.data = NEW.data THEN + RETURN NEW; + END IF; + + IF commit_txid_hex IS NOT NULL THEN + BEGIN + commit_txid_bytes := decode(commit_txid_hex, 'hex'); + EXCEPTION WHEN OTHERS THEN + commit_txid_bytes := NULL; + END; + END IF; + + INSERT INTO account_history + (address, prev_data, new_data, source, triggering_commit_txid) + VALUES + (NEW.address, + CASE WHEN TG_OP = 'UPDATE' THEN OLD.data ELSE NULL END, + NEW.data, + src, + commit_txid_bytes); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER accounts_history_trigger + AFTER INSERT OR UPDATE ON accounts + FOR EACH ROW + EXECUTE FUNCTION account_history_capture(); + +-- =========================================================================== +-- 7. username_claim_log: every claim attempt, success or reject +-- =========================================================================== +-- +-- The `usernames` table holds only the successful claims. Rejected +-- claims (squat attempts, malformed signatures, taken names) currently +-- return a 4xx and leave no trace. This table captures the attempt +-- itself for abuse forensics. + +CREATE TABLE username_claim_log ( + id BIGSERIAL PRIMARY KEY, + attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + requested_username TEXT NOT NULL, + normalized_username TEXT NOT NULL, + address BYTEA NOT NULL, + signature BYTEA NOT NULL, + success BOOLEAN NOT NULL, + reject_reason TEXT, + request_log_id BIGINT REFERENCES request_log (id) ON DELETE SET NULL +); + +CREATE INDEX username_claim_log_username_idx ON username_claim_log (normalized_username, attempted_at DESC); +CREATE INDEX username_claim_log_attempted_at_idx ON username_claim_log (attempted_at DESC); + +-- =========================================================================== +-- 8. tx_mining_log: reveal-txid prefix-mining attempts +-- =========================================================================== +-- +-- The publisher mines a reveal txid until it ends with the inscription +-- marker prefix (today `4242`). Today the only record of this work is +-- the stdout line "Tried N nonces…". This table records the per-mint +-- effort so the operator can spot a mining hang or a prefix change. + +CREATE TABLE tx_mining_log ( + id BIGSERIAL PRIMARY KEY, + mined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + target_prefix TEXT NOT NULL, + nonces_tried BIGINT NOT NULL, + duration_us BIGINT NOT NULL, + final_nonce BIGINT, + final_txid BYTEA NOT NULL, + commit_txid BYTEA +); + +CREATE INDEX tx_mining_log_mined_at_idx ON tx_mining_log (mined_at DESC); + +-- =========================================================================== +-- 9. coin_proof_store: persisted view of the in-memory ProofStore +-- =========================================================================== +-- +-- `ProofStore` lives in memory with a TTL; a crash between `/api/send` +-- (which generates the proof) and the client's matching `/api/commit` +-- (which references the proof by id) loses the proof and the client +-- has to retry. Persisting the proof bytes makes that recoverable. +-- +-- This migration creates the schema; the in-memory `ProofStore` +-- bootstrap is a follow-up — the table can be populated incrementally +-- without breaking the existing in-memory path. + +CREATE TABLE coin_proof_store ( + id BIGSERIAL PRIMARY KEY, + proof_id BIGINT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + consumed_by_commit_txid BYTEA, + proof_blob BYTEA NOT NULL +); + +CREATE INDEX coin_proof_store_expires_at_idx ON coin_proof_store (expires_at); + +-- =========================================================================== +-- 10. boot_log: server lifecycle events +-- =========================================================================== +-- +-- Captures the events that happen *before* the HTTP server starts +-- accepting requests (migration run, state load, vault sync, scanner +-- bootstrap) and the matching shutdown / panic events. Today these are +-- stdout-only; if a service flapped overnight the `received_at` of the +-- next successful boot is the only timestamp left. + +CREATE TABLE boot_log ( + id BIGSERIAL PRIMARY KEY, + event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + event_type TEXT NOT NULL, + message TEXT NOT NULL, + metadata JSONB +); + +CREATE INDEX boot_log_event_at_idx ON boot_log (event_at DESC); diff --git a/node/migrations/0009_schema_polish.sql b/node/migrations/0009_schema_polish.sql new file mode 100644 index 00000000..5bfa73b8 --- /dev/null +++ b/node/migrations/0009_schema_polish.sql @@ -0,0 +1,124 @@ +-- Schema polish on the 0006/0007/0008 stack. +-- +-- Closes the semantic / consistency gaps surfaced by the post-#118 +-- schema review: +-- +-- 1. `pending_inscriptions.reveal_txid` was nullable for defensive +-- reasons but every code path now sets it; ALTER it NOT NULL so +-- `loaders` can drop the `Option<>` and the schema reflects the +-- invariant. +-- 2. `block_log.received_at` and `processed_at` were both set to +-- `NOW()` in the same INSERT — one of them is dead weight. Drop +-- `received_at`, keep `processed_at NOT NULL DEFAULT NOW()` as +-- the single timestamp. WS-frame-receive logging (separate +-- event) is a future addition. +-- 3. `state_update_log.trigger` collides with Postgres trigger +-- vocabulary at every read; rename to `trigger_source`. No code +-- callers today so the rename is free. +-- 4. `esplora_log` gains `triggering_request_log_id` analogous to +-- `error_log` — outbound Esplora chatter caused by an inbound +-- HTTP request can now be joined back to its `request_log` row. +-- 5. `request_log` gains `client_ip` — the audit middleware now +-- surfaces the real client IP from `CF-Connecting-IP` / +-- `X-Forwarded-For` (everything zkcoins-node sees is behind a +-- Cloudflare Tunnel, so `remote_addr` is the tunnel endpoint, +-- not the client). `remote_addr` stays as the literal TCP peer +-- for transport-level forensics. +-- 6. `account_history_capture()` trigger reads the optional +-- `zkcoins.request_log_id` GUC so callers that have an HTTP +-- request context (audit middleware) can thread it through to +-- `account_history.triggering_request_log_id` without +-- reimplementing the upsert in application code. +-- +-- Closed test env stance unchanged: no backfill, no compat shims. + +-- 1. pending_inscriptions.reveal_txid NOT NULL --------------------------- +-- +-- All code paths fill reveal_txid since migration 0008; any row with +-- a NULL value can only come from a brief window between 0008's ADD +-- COLUMN landing and 0009 running. In the closed test env (see +-- `feedback_zkcoins_migrations_may_wipe`) such rows are throw-away: +-- wipe them explicitly so the SET NOT NULL never trips. New rows +-- start clean from the next publisher attempt. +DELETE FROM pending_inscriptions WHERE reveal_txid IS NULL; +ALTER TABLE pending_inscriptions + ALTER COLUMN reveal_txid SET NOT NULL; + +-- 2. block_log: single processed_at timestamp ---------------------------- +DROP INDEX IF EXISTS block_log_received_at_idx; +ALTER TABLE block_log DROP COLUMN received_at; +ALTER TABLE block_log + ALTER COLUMN processed_at SET DEFAULT NOW(), + ALTER COLUMN processed_at SET NOT NULL; +CREATE INDEX block_log_processed_at_idx ON block_log (processed_at DESC); + +-- 3. state_update_log column rename -------------------------------------- +ALTER TABLE state_update_log RENAME COLUMN trigger TO trigger_source; + +-- 4. esplora_log.triggering_request_log_id ------------------------------ +ALTER TABLE esplora_log + ADD COLUMN triggering_request_log_id BIGINT + REFERENCES request_log (id) ON DELETE SET NULL; +CREATE INDEX esplora_log_triggering_request_idx + ON esplora_log (triggering_request_log_id) + WHERE triggering_request_log_id IS NOT NULL; + +-- 5. request_log.client_ip ---------------------------------------------- +ALTER TABLE request_log + ADD COLUMN client_ip TEXT; +CREATE INDEX request_log_client_ip_idx + ON request_log (client_ip, received_at DESC) + WHERE client_ip IS NOT NULL; + +-- 6. account_history_capture() reads optional request_log_id GUC ------- +-- +-- The trigger now consults TWO per-transaction GUCs: +-- * `zkcoins.account_source` (already supported) — text source +-- * `zkcoins.account_commit_txid` (already supported) — hex bytea +-- * `zkcoins.request_log_id` (NEW) — request_log.id +-- +-- All three are read with `current_setting(..., TRUE)` so unset GUCs +-- silently fall back to the documented defaults (no error). Each +-- caller sets only the GUCs it knows about; the trigger captures +-- whatever is in scope. + +CREATE OR REPLACE FUNCTION account_history_capture() RETURNS TRIGGER AS $$ +DECLARE + src TEXT := COALESCE(NULLIF(current_setting('zkcoins.account_source', TRUE), ''), 'scanner'); + commit_txid_hex TEXT := NULLIF(current_setting('zkcoins.account_commit_txid', TRUE), ''); + commit_txid_bytes BYTEA := NULL; + req_log_id_text TEXT := NULLIF(current_setting('zkcoins.request_log_id', TRUE), ''); + req_log_id BIGINT := NULL; +BEGIN + IF TG_OP = 'UPDATE' AND OLD.data = NEW.data THEN + RETURN NEW; + END IF; + + IF commit_txid_hex IS NOT NULL THEN + BEGIN + commit_txid_bytes := decode(commit_txid_hex, 'hex'); + EXCEPTION WHEN OTHERS THEN + commit_txid_bytes := NULL; + END; + END IF; + + IF req_log_id_text IS NOT NULL THEN + BEGIN + req_log_id := req_log_id_text::BIGINT; + EXCEPTION WHEN OTHERS THEN + req_log_id := NULL; + END; + END IF; + + INSERT INTO account_history + (address, prev_data, new_data, source, triggering_commit_txid, triggering_request_log_id) + VALUES + (NEW.address, + CASE WHEN TG_OP = 'UPDATE' THEN OLD.data ELSE NULL END, + NEW.data, + src, + commit_txid_bytes, + req_log_id); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/node/migrations/0010_schema_polish_round2.sql b/node/migrations/0010_schema_polish_round2.sql new file mode 100644 index 00000000..df540202 --- /dev/null +++ b/node/migrations/0010_schema_polish_round2.sql @@ -0,0 +1,273 @@ +-- Second polish round on the persistence stack. +-- +-- Closes the remaining gaps from the round-2 review: +-- * Length CHECKs on every BYTEA column whose contents have a +-- domain-fixed size (txid 32, address 32, pubkey 33, sig 64). +-- * `block_log.block_height` nullable (was: NOT NULL with magic +-- sentinel `-1` for unknown). +-- * `pending_inscriptions.reveal_txid` UNIQUE (was: only commit_txid). +-- * Drop the now-redundant `WHERE reveal_txid IS NOT NULL` partial +-- filter on the reveal_txid index — column is NOT NULL since 0009. +-- * `tx_mining_log.commit_txid` NOT NULL (was nullable, always set). +-- * Missing FK constraints to `pending_inscriptions.commit_txid` +-- for `tx_mining_log` + `coin_proof_store`. +-- * Logical-pair CHECKs (integrated ⇔ integrated_at, success ⇔ +-- reject_reason, consumed_at ⇔ consumed_by_commit_txid, status= +-- 'failed' ⇒ failure_reason). +-- * `created_at` columns on the pre-existing tables that only had +-- `updated_at` (accounts, latest_block, smt_state, mmr_state). +-- * Align `esplora_log.triggered_by` with `state_update_log +-- .trigger_source`: same name, same CHECK vocabulary. +-- * Performance indices on commonly-joined FK columns. +-- * `boot_log.event_type` + `tx_mining_log.target_prefix` CHECKs. +-- * Rename `account_history_capture()` → `accounts_history_capture()` +-- to match the trigger / table noun. +-- * `mmr_root_index.leaf_index` UNIQUE. +-- +-- Per `feedback_zkcoins_migrations_may_wipe`: data is throw-away, +-- closed test env. Where a CHECK / NOT NULL could trip on legacy +-- rows we wipe them first. + +-- =========================================================================== +-- 1. BYTEA length CHECKs +-- =========================================================================== + +ALTER TABLE accounts + ADD CONSTRAINT accounts_address_length CHECK (octet_length(address) = 32); + +ALTER TABLE usernames + ADD CONSTRAINT usernames_address_length CHECK (octet_length(address) = 32); + +ALTER TABLE latest_block + ADD CONSTRAINT latest_block_hash_length CHECK (octet_length(block_hash) = 32); + +ALTER TABLE mmr_root_index + ADD CONSTRAINT mmr_root_index_prev_root_length CHECK (octet_length(prev_mmr_root) = 32), + ADD CONSTRAINT mmr_root_index_smt_root_length CHECK (octet_length(smt_root) = 32); + +ALTER TABLE pending_inscriptions + ADD CONSTRAINT pending_inscriptions_commit_txid_length CHECK (octet_length(commit_txid) = 32), + ADD CONSTRAINT pending_inscriptions_reveal_txid_length CHECK (octet_length(reveal_txid) = 32); + +ALTER TABLE block_log + ADD CONSTRAINT block_log_block_hash_length CHECK (octet_length(block_hash) = 32); + +-- observed_inscriptions: block_hash is nullable, so guard the CHECK. +ALTER TABLE observed_inscriptions + ADD CONSTRAINT observed_inscriptions_commit_txid_length CHECK (octet_length(commit_txid) = 32), + ADD CONSTRAINT observed_inscriptions_block_hash_length CHECK (block_hash IS NULL OR octet_length(block_hash) = 32), + ADD CONSTRAINT observed_inscriptions_public_key_length CHECK (octet_length(public_key) = 33); + +-- state_update_log: commit_txid is nullable. +ALTER TABLE state_update_log + ADD CONSTRAINT state_update_log_commit_txid_length CHECK (commit_txid IS NULL OR octet_length(commit_txid) = 32), + ADD CONSTRAINT state_update_log_prev_mmr_root_length CHECK (octet_length(prev_mmr_root) = 32), + ADD CONSTRAINT state_update_log_new_mmr_root_length CHECK (octet_length(new_mmr_root) = 32), + ADD CONSTRAINT state_update_log_smt_root_before_length CHECK (octet_length(smt_root_before) = 32), + ADD CONSTRAINT state_update_log_smt_root_after_length CHECK (octet_length(smt_root_after) = 32); + +ALTER TABLE account_history + ADD CONSTRAINT account_history_address_length CHECK (octet_length(address) = 32), + ADD CONSTRAINT account_history_triggering_commit_txid_length + CHECK (triggering_commit_txid IS NULL OR octet_length(triggering_commit_txid) = 32); + +ALTER TABLE username_claim_log + ADD CONSTRAINT username_claim_log_address_length CHECK (octet_length(address) = 32), + ADD CONSTRAINT username_claim_log_signature_length CHECK (octet_length(signature) = 64); + +ALTER TABLE tx_mining_log + ADD CONSTRAINT tx_mining_log_final_txid_length CHECK (octet_length(final_txid) = 32); +-- tx_mining_log.commit_txid handled below (becomes NOT NULL + FK + length CHECK) + +ALTER TABLE coin_proof_store + ADD CONSTRAINT coin_proof_store_consumed_txid_length + CHECK (consumed_by_commit_txid IS NULL OR octet_length(consumed_by_commit_txid) = 32); + +-- =========================================================================== +-- 2. block_log.block_height nullable (drop sentinel `-1`) +-- =========================================================================== + +UPDATE block_log SET block_height = NULL WHERE block_height = -1; +ALTER TABLE block_log ALTER COLUMN block_height DROP NOT NULL; + +-- =========================================================================== +-- 3. pending_inscriptions.reveal_txid UNIQUE +-- =========================================================================== + +ALTER TABLE pending_inscriptions + ADD CONSTRAINT pending_inscriptions_reveal_txid_unique UNIQUE (reveal_txid); + +-- The partial index from 0008 (`WHERE reveal_txid IS NOT NULL`) is now +-- redundant because reveal_txid is NOT NULL (0009) and UNIQUE adds +-- its own index. Drop the partial. +DROP INDEX IF EXISTS pending_inscriptions_reveal_txid_idx; + +-- =========================================================================== +-- 4. tx_mining_log.commit_txid NOT NULL + length CHECK + FK +-- =========================================================================== + +DELETE FROM tx_mining_log WHERE commit_txid IS NULL; +ALTER TABLE tx_mining_log + ALTER COLUMN commit_txid SET NOT NULL, + ADD CONSTRAINT tx_mining_log_commit_txid_length CHECK (octet_length(commit_txid) = 32), + ADD CONSTRAINT tx_mining_log_commit_txid_fk + FOREIGN KEY (commit_txid) REFERENCES pending_inscriptions (commit_txid) ON DELETE CASCADE; +CREATE INDEX tx_mining_log_commit_txid_idx ON tx_mining_log (commit_txid); + +-- =========================================================================== +-- 5. coin_proof_store.consumed_by_commit_txid FK +-- =========================================================================== + +ALTER TABLE coin_proof_store + ADD CONSTRAINT coin_proof_store_consumed_txid_fk + FOREIGN KEY (consumed_by_commit_txid) REFERENCES pending_inscriptions (commit_txid) ON DELETE SET NULL; + +-- =========================================================================== +-- 6. Logical-pair CHECKs (mutually-exclusive flag/timestamp pairs) +-- =========================================================================== + +ALTER TABLE observed_inscriptions ADD CONSTRAINT observed_inscriptions_integrated_consistency + CHECK ( + (integrated = TRUE AND integrated_at IS NOT NULL) + OR (integrated = FALSE AND integrated_at IS NULL) + ); + +ALTER TABLE username_claim_log ADD CONSTRAINT username_claim_log_outcome_consistency + CHECK ( + (success = TRUE AND reject_reason IS NULL) + OR (success = FALSE AND reject_reason IS NOT NULL) + ); + +ALTER TABLE coin_proof_store ADD CONSTRAINT coin_proof_store_consumption_consistency + CHECK ( + (consumed_at IS NULL AND consumed_by_commit_txid IS NULL) + OR (consumed_at IS NOT NULL AND consumed_by_commit_txid IS NOT NULL) + ); + +-- failure_reason is required whenever status = 'failed'; the inverse +-- direction (failure_reason set, status not 'failed') is permitted +-- because retries may leave a stale reason on an in-progress row. +ALTER TABLE pending_inscriptions ADD CONSTRAINT pending_inscriptions_failed_reason_required + CHECK (status <> 'failed' OR failure_reason IS NOT NULL); + +-- =========================================================================== +-- 7. Align esplora_log.triggered_by with state_update_log.trigger_source +-- =========================================================================== +-- +-- Same name, same vocabulary. Existing rows with values outside the +-- vocabulary are wiped (closed test env). + +DELETE FROM esplora_log +WHERE triggered_by IS NOT NULL + AND triggered_by NOT IN ('mint', 'send', 'scanner', 'recovery', 'health', 'resume'); + +ALTER TABLE esplora_log RENAME COLUMN triggered_by TO trigger_source; +ALTER TABLE esplora_log ADD CONSTRAINT esplora_log_trigger_source_check + CHECK (trigger_source IS NULL + OR trigger_source IN ('mint', 'send', 'scanner', 'recovery', 'health', 'resume')); + +-- =========================================================================== +-- 8. created_at on pre-existing singletons / state tables +-- =========================================================================== +-- +-- The 0001 tables tracked only `updated_at`; this leaves "when was +-- this account first seen?" answerable only via `account_history`. +-- Adding a NOT NULL DEFAULT NOW() backfills existing rows with the +-- migration timestamp — close enough for closed test env, accurate +-- for everything written from now on. + +ALTER TABLE accounts ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE latest_block ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE smt_state ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE mmr_state ADD COLUMN created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- =========================================================================== +-- 9. Performance indices +-- =========================================================================== + +CREATE INDEX account_history_triggering_commit_txid_idx + ON account_history (triggering_commit_txid, changed_at DESC) + WHERE triggering_commit_txid IS NOT NULL; + +CREATE INDEX pending_inscriptions_kind_idx + ON pending_inscriptions (kind, created_at DESC); + +CREATE INDEX pending_inscriptions_failed_idx + ON pending_inscriptions (updated_at DESC) + WHERE status = 'failed'; + +-- =========================================================================== +-- 10. boot_log.event_type + tx_mining_log.target_prefix CHECKs +-- =========================================================================== + +ALTER TABLE boot_log ADD CONSTRAINT boot_log_event_type_check + CHECK (event_type IN ('startup', 'shutdown', 'migration', 'state_load', 'vault_sync')); + +-- target_prefix is always a lowercase hex string. Keep the column +-- flexible (the marker may change) but validate the shape. +ALTER TABLE tx_mining_log ADD CONSTRAINT tx_mining_log_target_prefix_shape + CHECK (target_prefix ~ '^[0-9a-f]+$'); + +-- =========================================================================== +-- 11. Trigger function rename (cosmetic — match table noun) +-- =========================================================================== + +DROP TRIGGER accounts_history_trigger ON accounts; +DROP FUNCTION account_history_capture(); + +CREATE OR REPLACE FUNCTION accounts_history_capture() RETURNS TRIGGER AS $$ +DECLARE + src TEXT := COALESCE(NULLIF(current_setting('zkcoins.account_source', TRUE), ''), 'scanner'); + commit_txid_hex TEXT := NULLIF(current_setting('zkcoins.account_commit_txid', TRUE), ''); + commit_txid_bytes BYTEA := NULL; + req_log_id_text TEXT := NULLIF(current_setting('zkcoins.request_log_id', TRUE), ''); + req_log_id BIGINT := NULL; +BEGIN + IF TG_OP = 'UPDATE' AND OLD.data = NEW.data THEN + RETURN NEW; + END IF; + + IF commit_txid_hex IS NOT NULL THEN + BEGIN + commit_txid_bytes := decode(commit_txid_hex, 'hex'); + EXCEPTION WHEN OTHERS THEN + commit_txid_bytes := NULL; + END; + END IF; + + IF req_log_id_text IS NOT NULL THEN + BEGIN + req_log_id := req_log_id_text::BIGINT; + EXCEPTION WHEN OTHERS THEN + req_log_id := NULL; + END; + END IF; + + INSERT INTO account_history + (address, prev_data, new_data, source, triggering_commit_txid, triggering_request_log_id) + VALUES + (NEW.address, + CASE WHEN TG_OP = 'UPDATE' THEN OLD.data ELSE NULL END, + NEW.data, + src, + commit_txid_bytes, + req_log_id); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER accounts_history_trigger + AFTER INSERT OR UPDATE ON accounts + FOR EACH ROW + EXECUTE FUNCTION accounts_history_capture(); + +-- =========================================================================== +-- 12. mmr_root_index.leaf_index UNIQUE +-- =========================================================================== +-- +-- leaf_index is monotonic by construction (mmr.leaf_count()), so a +-- duplicate value is a code-side bug. UNIQUE turns that bug into a +-- constraint violation at insert time. + +ALTER TABLE mmr_root_index + ADD CONSTRAINT mmr_root_index_leaf_index_unique UNIQUE (leaf_index); diff --git a/node/src/audit.rs b/node/src/audit.rs new file mode 100644 index 00000000..26c92874 --- /dev/null +++ b/node/src/audit.rs @@ -0,0 +1,170 @@ +//! HTTP audit-log middleware. +//! +//! Captures every request/response that flows through the public router +//! and persists the pair into `request_log` (migration 0007). Runs as a +//! standard `axum::middleware::from_fn_with_state` layer so it sees the +//! full URI, headers, body bytes, and final response — including the +//! 4xx/5xx responses error handlers emit. +//! +//! Why fire-and-forget: the audit insert must NEVER block the response +//! back to the client. The middleware buffers both bodies in memory, +//! reconstructs the response, and spawns a tokio task that ships the +//! tuple to Postgres. A failed insert is logged to stderr and dropped — +//! losing an audit row is preferable to wedging the request handler on +//! a transient DB blip. +//! +//! Why buffering is safe today: every route in `router::create_router` +//! consumes a small JSON body and returns a small JSON response. No +//! streaming routes (no SSE, no WebSocket — those live on a separate +//! WS endpoint outside the audited router). If a streaming route is +//! ever added, that endpoint needs to opt out of this middleware to +//! avoid `body.collect()` materialising an unbounded stream. + +use axum::{ + body::{Body, Bytes}, + extract::{ConnectInfo, State}, + http::{HeaderMap, Request, Response}, + middleware::Next, +}; +use http_body_util::BodyExt; +use serde_json::{Map, Value}; +use std::net::SocketAddr; +use std::time::Instant; + +use crate::db; +use crate::router::AppState; + +/// Convert an `http::HeaderMap` into a `serde_json::Value` so it can be +/// stored as JSONB. Non-UTF-8 header values are rendered as a hex +/// debug string (`{"_binary":"…"}`) so the round-trip is still +/// reproducible — losing a single header to a bytes-only value would +/// cost more in forensics than the storage overhead. +fn headers_to_json(headers: &HeaderMap) -> Value { + let mut map = Map::with_capacity(headers.len()); + for (name, value) in headers.iter() { + let key = name.as_str().to_string(); + let val = match value.to_str() { + Ok(s) => Value::String(s.to_string()), + Err(_) => { + let mut binary = Map::with_capacity(1); + binary.insert( + "_binary".to_string(), + Value::String(hex::encode(value.as_bytes())), + ); + Value::Object(binary) + } + }; + // Headers can repeat (Set-Cookie etc.). Collapse repeats into + // an array under the same key — the JSONB schema stays flat + // string|array, which is easy to query. + match map.remove(&key) { + Some(Value::Array(mut arr)) => { + arr.push(val); + map.insert(key, Value::Array(arr)); + } + Some(existing) => { + map.insert(key, Value::Array(vec![existing, val])); + } + None => { + map.insert(key, val); + } + } + } + Value::Object(map) +} + +/// Best-effort `Body::collect()` that swallows the error and returns +/// the empty byte string. The body is already half-consumed by the +/// time an error surfaces (the underlying TCP connection broke or the +/// client cancelled), so the audit row will be incomplete either way — +/// the alternative of failing the request is worse. +async fn buffer_body(body: Body) -> Bytes { + match body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(e) => { + eprintln!("audit: body collect failed: {}", e); + Bytes::new() + } + } +} + +pub(crate) async fn audit_log_middleware( + State(state): State, + connect_info: Option>, + request: Request, + next: Next, +) -> Response { + let start = Instant::now(); + + let (req_parts, req_body) = request.into_parts(); + let req_bytes = buffer_body(req_body).await; + + let method = req_parts.method.to_string(); + let path = req_parts.uri.path().to_string(); + let query = req_parts.uri.query().map(|s| s.to_string()); + let remote_addr = connect_info.as_ref().map(|c| c.0.to_string()); + let user_agent = req_parts + .headers + .get(axum::http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + // Real client IP — zkcoins-node always runs behind a Cloudflare + // Tunnel, so the TCP peer (`remote_addr`) is the local cloudflared + // socket, not the user. Cloudflare injects the real client IP into + // `CF-Connecting-IP`. If a different proxy is ever in front of the + // tunnel (test setups, future direct ingress), fall back to the + // first segment of `X-Forwarded-For`, then to `remote_addr` as a + // last resort. + let client_ip = req_parts + .headers + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| { + req_parts + .headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .or_else(|| remote_addr.clone()); + let request_headers = headers_to_json(&req_parts.headers); + + // Reconstruct the request with the buffered body and forward. + let request = Request::from_parts(req_parts, Body::from(req_bytes.clone())); + let response = next.run(request).await; + + let (resp_parts, resp_body) = response.into_parts(); + let resp_bytes = buffer_body(resp_body).await; + let duration_us = i64::try_from(start.elapsed().as_micros()).unwrap_or(i64::MAX); + + let entry = db::RequestLogEntry { + method, + path, + query, + remote_addr, + client_ip, + user_agent, + request_headers, + request_body: req_bytes.to_vec(), + response_status: resp_parts.status.as_u16() as i16, + response_headers: headers_to_json(&resp_parts.headers), + response_body: resp_bytes.to_vec(), + duration_us, + }; + + // Fire-and-forget: a slow or failing audit write must not stall + // the response. The pool is cloned cheaply (it's an `Arc` + // under the hood). + let pool = state.pool.clone(); + tokio::spawn(async move { + if let Err(e) = db::insert_request_log(&pool, &entry).await { + eprintln!("audit: insert_request_log failed: {}", e); + } + }); + + Response::from_parts(resp_parts, Body::from(resp_bytes)) +} diff --git a/node/src/db.rs b/node/src/db.rs index e71de99f..a397668d 100644 --- a/node/src/db.rs +++ b/node/src/db.rs @@ -24,9 +24,48 @@ // later failure mode for schema drift, which the tests catch on the // first run. +use serde::{Deserialize, Serialize}; use sqlx::{postgres::PgPoolOptions, PgPool}; use zkcoins_program::hash::{digest_from_bytes, digest_to_bytes, HashDigest}; +/// Semantic classification of a `pending_inscriptions` row. +/// +/// Persisted in the `kind` column added by migration 0006. The two +/// variants correspond one-to-one with the two `create_and_broadcast_inscription` +/// callers: +/// +/// * `Mint` — `router::mint_handler` (server signs the commitment with +/// the minting account's index-N private key). +/// * `Send` — `runtime::broadcast_commit_and_deliver`, invoked from +/// `router::commit_handler` (client signs the commitment with their +/// wallet key, server only relays it on-chain). +/// +/// Persisting this is the difference between a DB row that tells you +/// *what happened* and one that only tells you *that something happened*. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InscriptionKind { + Mint, + Send, +} + +impl InscriptionKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Mint => "mint", + Self::Send => "send", + } + } + + pub fn from_db_str(s: &str) -> Option { + match s { + "mint" => Some(Self::Mint), + "send" => Some(Self::Send), + _ => None, + } + } +} + /// Connect to `url` and run every migration in `./migrations` against /// the pool. Returns the live pool on success. /// @@ -43,6 +82,397 @@ pub async fn connect_and_migrate(url: &str) -> Result { Ok(pool) } +// ---- Request audit log (migration 0007) ---------------------------------- +// +// Persist every HTTP request the node accepts, with the raw body and +// headers and the bytes of the response sent back. The server is not a +// privacy boundary — anyone who wants shielded operation runs their own +// node; the operator-side observation surface is fair game. + +/// In-memory view of a `request_log` row. Built by the audit middleware +/// (`audit::audit_log_middleware`) and shipped to `insert_request_log` +/// from a fire-and-forget tokio task so audit writes never block the +/// response back to the client. +#[derive(Debug, Clone)] +pub struct RequestLogEntry { + pub method: String, + pub path: String, + pub query: Option, + pub remote_addr: Option, + /// Real client IP, resolved by the audit middleware from + /// `CF-Connecting-IP` (Cloudflare Tunnel — the path zkcoins-node + /// actually serves on) with fallback to the first segment of + /// `X-Forwarded-For`, then `remote_addr`. Stored separately so + /// forensics can `WHERE client_ip = …` without parsing JSONB. + pub client_ip: Option, + pub user_agent: Option, + pub request_headers: serde_json::Value, + pub request_body: Vec, + pub response_status: i16, + pub response_headers: serde_json::Value, + pub response_body: Vec, + pub duration_us: i64, +} + +pub async fn insert_request_log(pool: &PgPool, entry: &RequestLogEntry) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO request_log \ + (method, path, query, remote_addr, client_ip, user_agent, \ + request_headers, request_body, \ + response_status, response_headers, response_body, \ + duration_us) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + ) + .bind(&entry.method) + .bind(&entry.path) + .bind(entry.query.as_deref()) + .bind(entry.remote_addr.as_deref()) + .bind(entry.client_ip.as_deref()) + .bind(entry.user_agent.as_deref()) + .bind(&entry.request_headers) + .bind(&entry.request_body) + .bind(entry.response_status) + .bind(&entry.response_headers) + .bind(&entry.response_body) + .bind(entry.duration_us) + .execute(pool) + .await?; + Ok(()) +} + +// ---- Full database trail (migration 0008) --------------------------------- +// +// Helpers for the tables added in `0008_full_database_trail.sql`. Each +// `insert_*` is a single-row insert; the caller decides whether to +// `await` synchronously (mint flow, where the persisted row should land +// before the request returns) or fire-and-forget via `tokio::spawn` +// (high-volume / non-critical paths like esplora REST chatter). + +#[derive(Debug, Clone)] +pub struct EsploraLogEntry { + pub direction: &'static str, // 'outbound_http' | 'outbound_ws' | 'inbound_ws' + pub method: Option, + pub url: String, + pub request_body: Option>, + pub response_status: Option, + pub response_body: Option>, + pub duration_us: Option, + /// One of `'mint' | 'send' | 'scanner' | 'recovery' | 'health' + /// | 'resume'`. Renamed from `triggered_by` in migration 0010 to + /// align with `state_update_log.trigger_source` (same name + same + /// CHECK vocabulary). `None` for paths without semantic context. + pub trigger_source: Option, + /// FK to `request_log.id` when the outbound call was issued + /// inside an HTTP handler. `None` for scanner / publisher / + /// background tasks. Added in migration 0009. + pub triggering_request_log_id: Option, +} + +pub async fn insert_esplora_log(pool: &PgPool, entry: &EsploraLogEntry) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO esplora_log \ + (direction, method, url, request_body, response_status, response_body, \ + duration_us, trigger_source, triggering_request_log_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + ) + .bind(entry.direction) + .bind(entry.method.as_deref()) + .bind(&entry.url) + .bind(entry.request_body.as_deref()) + .bind(entry.response_status) + .bind(entry.response_body.as_deref()) + .bind(entry.duration_us) + .bind(entry.trigger_source.as_deref()) + .bind(entry.triggering_request_log_id) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct ErrorLogEntry { + pub severity: &'static str, // 'warn' | 'error' | 'fatal' + pub source: String, + pub message: String, + pub error_chain: Option, + pub request_log_id: Option, +} + +pub async fn insert_error_log(pool: &PgPool, entry: &ErrorLogEntry) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO error_log \ + (severity, source, message, error_chain, request_log_id) \ + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(entry.severity) + .bind(&entry.source) + .bind(&entry.message) + .bind(entry.error_chain.as_deref()) + .bind(entry.request_log_id) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct BlockLogEntry { + pub block_hash: Vec, + /// Block height as reported by Esplora's `get_block_status`. `None` + /// when the upstream did not return a height — the previous + /// sentinel `-1` was magic-value-driven, NULL is the type-safe + /// alternative (migration 0010 drops the NOT NULL). + pub block_height: Option, + pub inscription_count: i32, + pub processing_duration_us: Option, +} + +/// Insert (or no-op on UNIQUE conflict — replayed blocks land twice +/// when the scanner restarts mid-stream). Marks `processed_at = NOW()` +/// in the same statement so the row reflects "scanner saw + processed +/// this block". +pub async fn insert_block_log(pool: &PgPool, entry: &BlockLogEntry) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO block_log \ + (block_hash, block_height, processed_at, inscription_count, processing_duration_us) \ + VALUES ($1, $2, NOW(), $3, $4) \ + ON CONFLICT (block_hash) DO NOTHING", + ) + .bind(&entry.block_hash) + .bind(entry.block_height) + .bind(entry.inscription_count) + .bind(entry.processing_duration_us) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct ObservedInscriptionEntry { + pub commit_txid: Vec, + pub block_hash: Option>, + pub block_height: Option, + pub source: &'static str, // 'own' | 'external' + pub commitment: Vec, + pub public_key: Vec, + pub integrated: bool, +} + +pub async fn insert_observed_inscription( + pool: &PgPool, + entry: &ObservedInscriptionEntry, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO observed_inscriptions \ + (commit_txid, block_hash, block_height, source, commitment, public_key, integrated, integrated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, CASE WHEN $7 THEN NOW() ELSE NULL END) \ + ON CONFLICT (commit_txid) DO NOTHING", + ) + .bind(&entry.commit_txid) + .bind(entry.block_hash.as_deref()) + .bind(entry.block_height) + .bind(entry.source) + .bind(&entry.commitment) + .bind(&entry.public_key) + .bind(entry.integrated) + .execute(pool) + .await?; + Ok(()) +} + +/// Flip an existing `observed_inscriptions` row to `integrated = true` +/// with `integrated_at = NOW()`. Called from the scanner callback +/// after `state.update` + the atomic `persist_state_tx` successfully +/// land the commitment in SMT/MMR. Idempotent — re-running the trigger +/// on a row that's already integrated is a no-op (the WHERE filters +/// out the already-flipped rows). +pub async fn mark_observed_inscription_integrated( + pool: &PgPool, + commit_txid: &[u8], +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE observed_inscriptions \ + SET integrated = TRUE, integrated_at = NOW() \ + WHERE commit_txid = $1 AND integrated = FALSE", + ) + .bind(commit_txid) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct StateUpdateLogEntry { + /// 'mint' | 'send' | 'scanner_replay' | 'recovery'. Renamed from + /// `trigger` in migration 0009 — the SQL keyword collision made + /// reads confusing. + pub trigger_source: &'static str, + pub commit_txid: Option>, + pub prev_mmr_root: Vec, + pub new_mmr_root: Vec, + pub smt_root_before: Vec, + pub smt_root_after: Vec, + pub commitment_count: i32, +} + +pub async fn insert_state_update_log( + pool: &PgPool, + entry: &StateUpdateLogEntry, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO state_update_log \ + (trigger_source, commit_txid, prev_mmr_root, new_mmr_root, \ + smt_root_before, smt_root_after, commitment_count) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(entry.trigger_source) + .bind(entry.commit_txid.as_deref()) + .bind(&entry.prev_mmr_root) + .bind(&entry.new_mmr_root) + .bind(&entry.smt_root_before) + .bind(&entry.smt_root_after) + .bind(entry.commitment_count) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct AccountHistoryEntry { + pub address: Vec, + pub prev_data: Option>, + pub new_data: Vec, + pub source: &'static str, // 'mint' | 'send' | 'receive' | 'scanner' | 'recovery' + pub triggering_commit_txid: Option>, + pub triggering_request_log_id: Option, +} + +pub async fn insert_account_history( + pool: &PgPool, + entry: &AccountHistoryEntry, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO account_history \ + (address, prev_data, new_data, source, triggering_commit_txid, triggering_request_log_id) \ + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(&entry.address) + .bind(entry.prev_data.as_deref()) + .bind(&entry.new_data) + .bind(entry.source) + .bind(entry.triggering_commit_txid.as_deref()) + .bind(entry.triggering_request_log_id) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct UsernameClaimLogEntry { + pub requested_username: String, + pub normalized_username: String, + pub address: Vec, + pub signature: Vec, + pub success: bool, + pub reject_reason: Option, + pub request_log_id: Option, +} + +pub async fn insert_username_claim_log( + pool: &PgPool, + entry: &UsernameClaimLogEntry, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO username_claim_log \ + (requested_username, normalized_username, address, signature, \ + success, reject_reason, request_log_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(&entry.requested_username) + .bind(&entry.normalized_username) + .bind(&entry.address) + .bind(&entry.signature) + .bind(entry.success) + .bind(entry.reject_reason.as_deref()) + .bind(entry.request_log_id) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct TxMiningLogEntry { + pub target_prefix: String, + pub nonces_tried: i64, + pub duration_us: i64, + pub final_nonce: Option, + pub final_txid: Vec, + pub commit_txid: Option>, +} + +pub async fn insert_tx_mining_log( + pool: &PgPool, + entry: &TxMiningLogEntry, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO tx_mining_log \ + (target_prefix, nonces_tried, duration_us, final_nonce, final_txid, commit_txid) \ + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(&entry.target_prefix) + .bind(entry.nonces_tried) + .bind(entry.duration_us) + .bind(entry.final_nonce) + .bind(&entry.final_txid) + .bind(entry.commit_txid.as_deref()) + .execute(pool) + .await?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct BootLogEntry { + pub event_type: String, + pub message: String, + pub metadata: Option, +} + +pub async fn insert_boot_log(pool: &PgPool, entry: &BootLogEntry) -> Result<(), sqlx::Error> { + sqlx::query("INSERT INTO boot_log (event_type, message, metadata) VALUES ($1, $2, $3)") + .bind(&entry.event_type) + .bind(&entry.message) + .bind(entry.metadata.as_ref()) + .execute(pool) + .await?; + Ok(()) +} + +/// Mark a `pending_inscriptions` row as definitively failed: status = +/// `'failed'` and `failure_reason` set, both in one UPDATE. Pairs the +/// status discriminator with the error-chain text so the resume path +/// can skip permanently-failed rows AND the operator can answer +/// "why?" from SQL alone. Called from the publisher's error paths. +/// +/// Note: the existing `resume_pending_inscriptions` still loads +/// `status <> 'complete'` and re-drives `failed` rows. If a stricter +/// policy is wanted later (skip `failed` outright), that's a one-line +/// SQL change in `load_pending_in_progress`. +pub async fn mark_pending_failed( + pool: &PgPool, + commit_txid: &[u8], + failure_reason: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + "UPDATE pending_inscriptions \ + SET status = 'failed', failure_reason = $1, updated_at = NOW() \ + WHERE commit_txid = $2", + ) + .bind(failure_reason) + .bind(commit_txid) + .execute(pool) + .await?; + Ok(()) +} + // ---- State persistence (PR-A2) -------------------------------------------- /// Load the bincode-serialized Sparse Merkle Tree blob. @@ -423,11 +853,14 @@ pub const PENDING_STATUS_COMPLETE: &str = "complete"; pub struct PendingInscriptionRow { pub id: i64, pub commit_txid: Vec, + pub reveal_txid: Option>, pub status: String, + pub kind: InscriptionKind, pub commitment: Vec, pub commit_tx: Vec, pub reveal_tx: Vec, pub commit_output_value: i64, + pub failure_reason: Option, } /// Insert a fresh `constructed` row before the publisher attempts the @@ -439,9 +872,12 @@ pub struct PendingInscriptionRow { /// crashed before completing), the function returns `Ok(false)` so the /// caller can carry on with the existing row instead of double- /// inserting. Every other DB error propagates. +#[allow(clippy::too_many_arguments)] pub async fn insert_pending_inscription( pool: &PgPool, commit_txid: &[u8], + reveal_txid: &[u8], + kind: InscriptionKind, commitment: &[u8], commit_tx: &[u8], reveal_tx: &[u8], @@ -449,12 +885,14 @@ pub async fn insert_pending_inscription( ) -> Result { let result = sqlx::query( "INSERT INTO pending_inscriptions \ - (commit_txid, status, commitment, commit_tx, reveal_tx, commit_output_value) \ - VALUES ($1, $2, $3, $4, $5, $6) \ + (commit_txid, reveal_txid, status, kind, commitment, commit_tx, reveal_tx, commit_output_value) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ ON CONFLICT (commit_txid) DO NOTHING", ) .bind(commit_txid) + .bind(reveal_txid) .bind(PENDING_STATUS_CONSTRUCTED) + .bind(kind.as_str()) .bind(commitment) .bind(commit_tx) .bind(reveal_tx) @@ -523,35 +961,164 @@ pub async fn pending_inscription_status_by_commit_txid( pub async fn load_pending_in_progress( pool: &PgPool, ) -> Result, sqlx::Error> { - // Tuple layout: (id, commit_txid, status, commitment, commit_tx, - // reveal_tx, commit_output_value). Aliased to keep the - // `sqlx::query_as` annotation under clippy's `type_complexity` - // threshold. - type RawRow = (i64, Vec, String, Vec, Vec, Vec, i64); + // Tuple layout: (id, commit_txid, reveal_txid, status, kind, + // commitment, commit_tx, reveal_tx, commit_output_value, + // failure_reason). Aliased to keep the `sqlx::query_as` + // annotation under clippy's `type_complexity` threshold. + type RawRow = ( + i64, + Vec, + Option>, + String, + String, + Vec, + Vec, + Vec, + i64, + Option, + ); let rows: Vec = sqlx::query_as( - "SELECT id, commit_txid, status, commitment, commit_tx, reveal_tx, commit_output_value \ + "SELECT id, commit_txid, reveal_txid, status, kind, commitment, commit_tx, reveal_tx, \ + commit_output_value, failure_reason \ FROM pending_inscriptions \ WHERE status <> 'complete' \ ORDER BY id", ) .fetch_all(pool) .await?; - Ok(rows - .into_iter() + rows.into_iter() .map( - |(id, commit_txid, status, commitment, commit_tx, reveal_tx, commit_output_value)| { - PendingInscriptionRow { + |( + id, + commit_txid, + reveal_txid, + status, + kind, + commitment, + commit_tx, + reveal_tx, + commit_output_value, + failure_reason, + )| { + let kind = InscriptionKind::from_db_str(&kind).ok_or_else(|| { + sqlx::Error::Decode( + format!("invalid pending_inscriptions.kind value: {kind:?}").into(), + ) + })?; + Ok(PendingInscriptionRow { id, commit_txid, + reveal_txid, status, + kind, commitment, commit_tx, reveal_tx, commit_output_value, - } + failure_reason, + }) }, ) - .collect()) + .collect() +} + +/// Lookup the public-facing view of a single inscription by its commit +/// txid. Used by the `GET /api/inscriptions/:txid` endpoint to surface +/// the `(kind, status, value, timestamps)` tuple without exposing the +/// raw commit/reveal/commitment blobs (which are useful for crash +/// recovery but not for operator/forensic queries). +/// +/// Returns `Ok(None)` when no row exists — either because this server +/// never originated the inscription (e.g. an external recovery via the +/// `recover_inscription` CLI) or because the txid was never seen here. +#[derive(Debug, Clone, Serialize)] +pub struct InscriptionSummary { + /// Commit txid as a lowercase hex string. Mirrors the on-chain + /// txid shown in block explorers — i.e. big-endian display order, + /// the reverse of the raw `bytea` stored in the column. + pub commit_txid: String, + /// Reveal txid in the same display-order convention. `None` only + /// for rows that pre-date migration 0008 (no production rows, see + /// migration 0006 wipe). + pub reveal_txid: Option, + pub kind: InscriptionKind, + pub status: String, + pub commit_output_value: i64, + /// Error chain when `status = 'failed'`, otherwise `None`. + pub failure_reason: Option, + /// ISO-8601 / RFC-3339 UTC timestamp, formatted in Postgres so we + /// can stay off the `chrono`/`time` sqlx feature flags. Microsecond + /// precision; trailing `Z` to make the timezone explicit. + pub created_at: String, + pub updated_at: String, +} + +pub async fn get_inscription_summary_by_commit_txid( + pool: &PgPool, + commit_txid: &[u8], +) -> Result, sqlx::Error> { + type RawRow = ( + Vec, + Option>, + String, + String, + i64, + Option, + String, + String, + ); + let row: Option = sqlx::query_as( + "SELECT commit_txid, \ + reveal_txid, \ + kind, \ + status, \ + commit_output_value, \ + failure_reason, \ + to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS created_at, \ + to_char(updated_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US\"Z\"') AS updated_at \ + FROM pending_inscriptions \ + WHERE commit_txid = $1", + ) + .bind(commit_txid) + .fetch_optional(pool) + .await?; + row.map( + |( + commit_txid_bytes, + reveal_txid_bytes, + kind, + status, + commit_output_value, + failure_reason, + created_at, + updated_at, + )| { + let kind = InscriptionKind::from_db_str(&kind).ok_or_else(|| { + sqlx::Error::Decode( + format!("invalid pending_inscriptions.kind value: {kind:?}").into(), + ) + })?; + // Reverse to display order — txid in explorers is the + // little-endian-stored bytes shown big-endian. + let mut commit_display = commit_txid_bytes; + commit_display.reverse(); + let reveal_txid = reveal_txid_bytes.map(|mut b| { + b.reverse(); + hex::encode(b) + }); + Ok(InscriptionSummary { + commit_txid: hex::encode(commit_display), + reveal_txid, + kind, + status, + commit_output_value, + failure_reason, + created_at, + updated_at, + }) + }, + ) + .transpose() } // ---- MMR root index persistence (Phase C) --------------------------------- diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs index 7d4f5bb7..a194dd1e 100644 --- a/node/src/db_tests.rs +++ b/node/src/db_tests.rs @@ -443,12 +443,15 @@ async fn pending_inscription_status_by_commit_txid_returns_none_for_unknown_txid async fn pending_inscription_status_by_commit_txid_returns_current_status() { let (pool, _container) = setup_pool().await; let commit_txid = [0xCDu8; 32]; + let reveal_txid = [0xCEu8; 32]; let commitment = b"test-commitment"; let commit_tx = b"test-commit-tx"; let reveal_tx = b"test-reveal-tx"; insert_pending_inscription( &pool, &commit_txid, + &reveal_txid, + InscriptionKind::Mint, commitment, commit_tx, reveal_tx, @@ -489,9 +492,15 @@ async fn pending_inscription_status_by_commit_txid_returns_current_status() { /// Helper: insert a `pending_inscriptions` row in the given starting /// status so the atomic-tx tests can exercise the mark-complete step. async fn seed_pending_row(pool: &PgPool, commit_txid: &[u8], status: &str) { + // Synthetic reveal txid for tests — not derived from the seed + // bytes since this helper is only used to drive the status state + // machine, not the reveal-txid lookup. + let reveal_txid: [u8; 32] = [0xAB; 32]; insert_pending_inscription( pool, commit_txid, + &reveal_txid, + InscriptionKind::Mint, b"test-commitment", b"test-commit-tx", b"test-reveal-tx", diff --git a/node/src/lib.rs b/node/src/lib.rs index e91e02c3..3177c9f9 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -26,6 +26,7 @@ #![allow(clippy::new_without_default)] pub mod account_node; +pub mod audit; pub mod db; pub mod publisher; pub mod router; diff --git a/node/src/main.rs b/node/src/main.rs index 2a5b4839..a09c7512 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -139,6 +139,7 @@ async fn main() -> Result<(), Box> { // Clones for the scanner callback closure. let pool_for_callback = Arc::clone(&pool); + let pool_for_scanner = (*pool).clone(); let state_for_callback = Arc::clone(&state); // Event-driven chain ingestion (issue #84). The previous @@ -162,7 +163,7 @@ async fn main() -> Result<(), Box> { let (tip_tx, tip_rx) = mpsc::channel::(64); tokio::spawn(run_scanner_ws(ws_config, tip_tx)); - scan_for_inscriptions(network_config, start_block_hash, &move |content_bytes: Vec, commit_txid, current_block_hash| { + scan_for_inscriptions(network_config, start_block_hash, Some(pool_for_scanner), &move |content_bytes: Vec, commit_txid, current_block_hash| { println!("Received content size: {} bytes", content_bytes.len()); // Try to deserialize the content as a Commitment @@ -196,6 +197,42 @@ async fn main() -> Result<(), Box> { &pool_for_callback, commit_txid_bytes, ); + + // observed_inscriptions: every commitment the scanner + // extracts from on-chain gets a row, regardless of + // whether `state.update` runs. `source` flags whether + // this came from our own publisher (pending row exists) + // or another operator's node / a recovery CLI. Captured + // here — once per call — so the row's `commitment` / + // `public_key` columns survive even if the early-return + // below short-circuits the rest of the callback. + { + let source: &'static str = if pending_status.is_some() { + "own" + } else { + "external" + }; + let entry = node::db::ObservedInscriptionEntry { + commit_txid: commit_txid_bytes.to_vec(), + block_hash: Some(current_block_hash.to_byte_array().to_vec()), + block_height: None, // not in scanner callback scope today + source, + commitment: content_bytes.clone(), + public_key: commitment.public_key.serialize().to_vec(), + integrated: false, // will be flipped post-state.update below + }; + let pool = (*pool_for_callback).clone(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + if let Err(e) = + node::db::insert_observed_inscription(&pool, &entry).await + { + eprintln!("Failed to persist observed_inscription: {}", e); + } + }); + }); + } + if node::scanner::should_skip_scanner_state_update(pending_status.as_deref()) { println!( "scanner: commit {} already integrated by mint_handler — skipping state.update", @@ -304,6 +341,35 @@ async fn main() -> Result<(), Box> { ); } } + + // Flip the matching `observed_inscriptions` + // row to `integrated = true, integrated_at + // = NOW()`. The row was inserted earlier + // in this callback with `integrated = + // false`; the UPDATE is the second half of + // the two-step lifecycle (insert at + // observation, mark integrated after the + // SMT/MMR write lands). Idempotent — the + // WHERE filter is keyed on `integrated = + // FALSE` so re-runs (scanner replay) are a + // no-op. + let pool_clone = (*pool_for_callback).clone(); + let txid_bytes = commit_txid_bytes.to_vec(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async move { + if let Err(e) = node::db::mark_observed_inscription_integrated( + &pool_clone, + &txid_bytes, + ) + .await + { + eprintln!( + "Failed to flip observed_inscriptions.integrated: {}", + e + ); + } + }); + }); } Err(e) => eprintln!("persist_state_tx failed: {}", e), } diff --git a/node/src/publisher.rs b/node/src/publisher.rs index 7fdd7182..d8efa055 100644 --- a/node/src/publisher.rs +++ b/node/src/publisher.rs @@ -98,13 +98,26 @@ fn min_fee(tx: &Transaction, witness_weight: Option) -> u64 { weight.div_ceil(4) } +/// Telemetry from `inscription_txs`' reveal-txid prefix-mining loop. +/// Returned alongside the constructed transactions so the caller can +/// persist a row to `tx_mining_log` for forensics — answering "did the +/// mining stall?" / "how much CPU did this Send cost?" from SQL. +#[derive(Debug, Clone)] +pub struct MiningStats { + pub target_prefix: String, + pub nonces_tried: i64, + pub duration_us: i64, + pub final_nonce: Option, + pub final_txid: bitcoin::Txid, +} + pub fn inscription_txs( commitment_data: &[u8], publisher_address: &Address, outpoints_with_sats: Vec<(OutPoint, u64)>, publisher_key: &str, config: &EsploraConfig, -) -> (Transaction, Transaction) { +) -> (Transaction, Transaction, MiningStats) { // Create secp context and keys let secp256k1 = Secp256k1::new(); let sk = SecretKey::from_str(publisher_key).unwrap(); @@ -185,7 +198,7 @@ pub fn inscription_txs( let commit_txid = commit_tx.compute_txid(); let commit_output_value = commit_tx.output[0].value.to_sat(); - let reveal_tx = build_reveal_only_inner( + let (reveal_tx, stats) = build_reveal_only_inner( commit_txid, commit_output_value, publisher_address, @@ -195,7 +208,7 @@ pub fn inscription_txs( &secp256k1, ); - (commit_tx, reveal_tx) + (commit_tx, reveal_tx, stats) } /// Internal helper carrying the script-path anchor artefacts that both @@ -283,7 +296,7 @@ pub fn build_reveal_only( taproot_spend_info, } = build_taproot_anchor(commitment_data, public_key, network); - let reveal_tx = build_reveal_only_inner( + let (reveal_tx, _stats) = build_reveal_only_inner( commit_txid, commit_output_value, publisher_address, @@ -308,7 +321,7 @@ fn build_reveal_only_inner( reveal_script: &ScriptBuf, taproot_spend_info: &bitcoin::taproot::TaprootSpendInfo, secp256k1: &Secp256k1, -) -> Transaction { +) -> (Transaction, MiningStats) { // The reveal spends the commit anchor; mirror the prevout `TxOut` // used for signing so the legacy and recovery paths produce a // byte-identical witness for the same inputs. The scriptPubKey is @@ -350,7 +363,12 @@ fn build_reveal_only_inner( .control_block(&(reveal_script.clone(), LeafVersion::TapScript)) .unwrap(); + let mining_start = std::time::Instant::now(); + let mut found_nonce: Option = None; + let mut nonces_seen: u32 = 0; + for nonce in 0..MAX_MINING_ATTEMPTS { + nonces_seen = nonce; // Update the nSequence for mining reveal_tx.input[0].sequence = Sequence(nonce); @@ -380,6 +398,7 @@ fn build_reveal_only_inner( if txid_bytes.starts_with(&target_prefix) { println!("Found matching txid: {} with nSequence: {}", txid, nonce); + found_nonce = Some(nonce); break; } @@ -392,7 +411,16 @@ fn build_reveal_only_inner( } } - reveal_tx + let final_txid = reveal_tx.compute_txid(); + let stats = MiningStats { + target_prefix: INSCRIPTION_MARKER_PREFIX.to_string(), + nonces_tried: i64::from(nonces_seen) + 1, + duration_us: i64::try_from(mining_start.elapsed().as_micros()).unwrap_or(i64::MAX), + final_nonce: found_nonce.map(i64::from), + final_txid, + }; + + (reveal_tx, stats) } /// Broadcasts the commit and reveal transactions to the Bitcoin @@ -561,6 +589,7 @@ pub async fn get_publisher_utxo( /// pre-Phase-B version — no DB writes, no resume hooks. pub async fn create_and_broadcast_inscription( commitment_data: &[u8], + kind: db::InscriptionKind, config: &EsploraConfig, pool: Option<&PgPool>, ) -> Result<(Txid, Txid), Box> { @@ -598,7 +627,7 @@ pub async fn create_and_broadcast_inscription( } // Create the inscription transactions - let (commit_tx, reveal_tx) = inscription_txs( + let (commit_tx, reveal_tx, mining_stats) = inscription_txs( commitment_data, &publisher_address, outpoints_with_sats, @@ -627,6 +656,8 @@ pub async fn create_and_broadcast_inscription( match db::insert_pending_inscription( pool, commit_txid.as_byte_array(), + reveal_txid.as_byte_array(), + kind, commitment_data, &commit_tx_bytes, &reveal_tx_bytes, @@ -660,6 +691,27 @@ pub async fn create_and_broadcast_inscription( return Err(format!("persist pending inscription: {}", e).into()); } } + + // tx_mining_log: persist the reveal-txid prefix-mining effort + // (nonces tried, duration, final nonce + txid). Fire-and-forget + // because mining-stat loss is preferable to a Send failing on + // a transient DB blip. + { + let pool = pool.clone(); + let mining_entry = db::TxMiningLogEntry { + target_prefix: mining_stats.target_prefix.clone(), + nonces_tried: mining_stats.nonces_tried, + duration_us: mining_stats.duration_us, + final_nonce: mining_stats.final_nonce, + final_txid: mining_stats.final_txid.as_byte_array().to_vec(), + commit_txid: Some(commit_txid.as_byte_array().to_vec()), + }; + tokio::spawn(async move { + if let Err(e) = db::insert_tx_mining_log(&pool, &mining_entry).await { + eprintln!("Failed to persist tx_mining_log: {}", e); + } + }); + } } // Broadcast the transactions @@ -672,6 +724,22 @@ pub async fn create_and_broadcast_inscription( } Err(e) => { println!("Failed to broadcast transactions: {}", e); + // Mark the row as definitively failed: status = 'failed' AND + // failure_reason set in one UPDATE. The status discriminator + // matches the error chain text so the operator can answer + // "why did this Send not land?" from SQL alone, and the + // CHECK-allowed `failed` value is no longer dead code. + if let Some(pool) = pool { + let reason = format!("{}", e); + if let Err(persist_err) = + db::mark_pending_failed(pool, commit_txid.as_byte_array(), &reason).await + { + eprintln!( + "Failed to mark pending_inscriptions row as failed for {}: {}", + commit_txid, persist_err + ); + } + } Err(e) } } diff --git a/node/src/publisher_tests.rs b/node/src/publisher_tests.rs index 06073675..57a9faf2 100644 --- a/node/src/publisher_tests.rs +++ b/node/src/publisher_tests.rs @@ -132,7 +132,7 @@ fn inscription_txs_produces_taproot_commit_and_reveal_with_marker_prefix() { let publisher_address = test_publisher_address(config.network()); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; - let (commit_tx, reveal_tx) = inscription_txs( + let (commit_tx, reveal_tx, _stats) = inscription_txs( b"Hello, zkCoins!", &publisher_address, outpoints, @@ -173,7 +173,7 @@ fn inscription_txs_embeds_commitment_data_in_reveal_script() { let payload = b"Hello, zkCoins!".to_vec(); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; - let (_commit_tx, reveal_tx) = inscription_txs( + let (_commit_tx, reveal_tx, _stats) = inscription_txs( &payload, &publisher_address, outpoints, @@ -241,7 +241,7 @@ fn inscription_txs_chunks_large_commitment_data() { let payload: Vec = (0..600).map(|i| (i % 255 + 1) as u8).collect(); let outpoints = vec![(fake_outpoint(0), 200_000u64)]; - let (_commit_tx, reveal_tx) = inscription_txs( + let (_commit_tx, reveal_tx, _stats) = inscription_txs( &payload, &publisher_address, outpoints, @@ -300,7 +300,7 @@ fn inscription_txs_signs_commit_input_with_taproot_keyspend() { let publisher_address = test_publisher_address(config.network()); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; - let (commit_tx, _reveal_tx) = inscription_txs( + let (commit_tx, _reveal_tx, _stats) = inscription_txs( b"Hello, zkCoins!", &publisher_address, outpoints, @@ -443,7 +443,7 @@ async fn broadcast_inscription_txs_returns_both_txids_on_success() { // Build a real (commit, reveal) pair — broadcast just serialises and // POSTs them, so the txids the function returns are the ones we // computed locally. - let (commit_tx, reveal_tx) = inscription_txs( + let (commit_tx, reveal_tx, _stats) = inscription_txs( b"Hello, zkCoins!", &publisher_address, outpoints, @@ -483,7 +483,7 @@ async fn broadcast_inscription_txs_errors_when_track_tx_event_never_arrives() { let publisher_address = test_publisher_address(config.network()); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; - let (commit_tx, reveal_tx) = inscription_txs( + let (commit_tx, reveal_tx, _stats) = inscription_txs( b"Hello, zkCoins!", &publisher_address, outpoints, @@ -516,7 +516,7 @@ async fn broadcast_inscription_txs_propagates_esplora_error() { let publisher_address = test_publisher_address(config.network()); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; - let (commit_tx, reveal_tx) = inscription_txs( + let (commit_tx, reveal_tx, _stats) = inscription_txs( b"Hello, zkCoins!", &publisher_address, outpoints, @@ -556,9 +556,14 @@ async fn create_and_broadcast_inscription_fails_when_no_utxos() { .mount(&server) .await; - let err = create_and_broadcast_inscription(b"Hello, zkCoins!", &config, None) - .await - .expect_err("empty wallet must produce an Err"); + let err = create_and_broadcast_inscription( + b"Hello, zkCoins!", + db::InscriptionKind::Mint, + &config, + None, + ) + .await + .expect_err("empty wallet must produce an Err"); assert!( err.to_string().contains("No UTXOs available"), @@ -596,10 +601,14 @@ async fn create_and_broadcast_inscription_succeeds_end_to_end_with_mocked_esplor .mount(&server) .await; - let (commit_txid, reveal_txid) = - create_and_broadcast_inscription(b"Hello, zkCoins!", &config, None) - .await - .expect("end-to-end inscription should succeed against mocked Esplora"); + let (commit_txid, reveal_txid) = create_and_broadcast_inscription( + b"Hello, zkCoins!", + db::InscriptionKind::Mint, + &config, + None, + ) + .await + .expect("end-to-end inscription should succeed against mocked Esplora"); assert_ne!( commit_txid, reveal_txid, "commit and reveal must be distinct transactions" @@ -691,13 +700,14 @@ fn build_test_pair(commitment_data: &[u8]) -> (Transaction, Transaction) { }; let publisher_address = test_publisher_address(config.network()); let outpoints = vec![(fake_outpoint(0), 100_000u64)]; - inscription_txs( + let (commit_tx, reveal_tx, _stats) = inscription_txs( commitment_data, &publisher_address, outpoints, TEST_PUBLISHER_KEY, &config, - ) + ); + (commit_tx, reveal_tx) } /// Insert a row in the supplied state directly via the db helper. Used @@ -710,12 +720,15 @@ async fn seed_pending_row( status: &str, ) { let commit_txid = commit_tx.compute_txid(); + let reveal_txid = reveal_tx.compute_txid(); let commit_tx_bytes = bitcoin::consensus::serialize(commit_tx); let reveal_tx_bytes = bitcoin::consensus::serialize(reveal_tx); let commit_output_value = commit_tx.output[0].value.to_sat() as i64; let inserted = db::insert_pending_inscription( pool, commit_txid.as_byte_array(), + reveal_txid.as_byte_array(), + db::InscriptionKind::Mint, commitment_data, &commit_tx_bytes, &reveal_tx_bytes, @@ -761,9 +774,14 @@ async fn broadcast_persists_constructed_row_before_commit_broadcast() { .mount(&server) .await; - let _err = create_and_broadcast_inscription(b"phaseb-1", &config, Some(&pool)) - .await - .expect_err("broadcast must fail (400)"); + let _err = create_and_broadcast_inscription( + b"phaseb-1", + db::InscriptionKind::Mint, + &config, + Some(&pool), + ) + .await + .expect_err("broadcast must fail (400)"); // Exactly one row, status = constructed (commit broadcast failed // so the advance to `commit_broadcast` never fired). @@ -817,9 +835,14 @@ async fn broadcast_advances_to_commit_broadcast_after_commit_success() { .mount(&server) .await; - let _err = create_and_broadcast_inscription(b"phaseb-2", &config, Some(&pool)) - .await - .expect_err("WS timeout (silent mock + no REST fallback) must surface"); + let _err = create_and_broadcast_inscription( + b"phaseb-2", + db::InscriptionKind::Mint, + &config, + Some(&pool), + ) + .await + .expect_err("WS timeout (silent mock + no REST fallback) must surface"); // One row, advanced from `constructed` to `commit_broadcast` by // the commit-OK hook but stuck there because the reveal step @@ -867,9 +890,14 @@ async fn broadcast_advances_to_reveal_broadcast_after_reveal_success() { .mount(&server) .await; - let _result = create_and_broadcast_inscription(b"phaseb-3", &config, Some(&pool)) - .await - .expect("happy path must succeed"); + let _result = create_and_broadcast_inscription( + b"phaseb-3", + db::InscriptionKind::Mint, + &config, + Some(&pool), + ) + .await + .expect("happy path must succeed"); // Final state is `reveal_broadcast` — see Phase E note above. assert_eq!(count_pending_rows(&pool).await, 1); @@ -1211,10 +1239,14 @@ async fn mint_handler_advances_state_synchronously_with_broadcast() { .mount(&server) .await; - let (commit_txid, _reveal_txid) = - create_and_broadcast_inscription(b"phase-e-1", &config, Some(&pool)) - .await - .expect("happy path must succeed"); + let (commit_txid, _reveal_txid) = create_and_broadcast_inscription( + b"phase-e-1", + db::InscriptionKind::Mint, + &config, + Some(&pool), + ) + .await + .expect("happy path must succeed"); // Publisher leg stopped at `reveal_broadcast` — the `mint_handler` // caller is what flips it to `complete` after running @@ -1248,10 +1280,13 @@ async fn mint_handler_advances_state_synchronously_with_broadcast() { async fn scanner_skips_already_integrated_commit_on_replay() { let (pool, _container) = setup_phaseb_pool().await; let commit_txid = [0x42u8; 32]; + let reveal_txid = [0x43u8; 32]; db::insert_pending_inscription( &pool, &commit_txid, + &reveal_txid, + db::InscriptionKind::Mint, b"phase-e-2", b"commit-tx-bytes", b"reveal-tx-bytes", @@ -1300,9 +1335,12 @@ async fn scanner_falls_back_to_state_update_for_commits_not_in_pending() { // complete — status is still `reveal_broadcast`. The scanner is // the recovery path here. let crashed_txid = [0x55u8; 32]; + let crashed_reveal_txid = [0x56u8; 32]; db::insert_pending_inscription( &pool, &crashed_txid, + &crashed_reveal_txid, + db::InscriptionKind::Send, b"phase-e-3-crashed", b"commit-tx-crashed", b"reveal-tx-crashed", diff --git a/node/src/router.rs b/node/src/router.rs index 8247c37b..6986311f 100644 --- a/node/src/router.rs +++ b/node/src/router.rs @@ -1016,6 +1016,7 @@ async fn mint_handler( // next scanner sweep. No in-handler retry. let broadcast_outcome = create_and_broadcast_inscription( &commitment_data, + crate::db::InscriptionKind::Mint, &state.esplora_config, Some(&state.pool), ) @@ -1350,6 +1351,60 @@ async fn commit_handler( .await } +/// `GET /api/inscriptions/:txid` — operator/forensics lookup of a single +/// inscription by its commit txid. Surfaces the columns that answer +/// "what kind of operation was this, and where is it in the publish +/// pipeline" without exposing the raw commit/reveal/commitment blobs +/// (those are crash-recovery state, not user-facing). +/// +/// Returns 404 when no row exists — the inscription either never went +/// through this server (e.g. external recovery via `recover_inscription` +/// CLI) or the txid is unknown. +async fn get_inscription_handler( + State(state): State, + Path(txid_hex): Path, +) -> axum::response::Response { + // Bitcoin convention: display txids are big-endian, but the + // `pending_inscriptions.commit_txid` column stores raw little-endian + // bytes (matching `bitcoin::Txid::as_byte_array()` semantics — see + // `publisher.rs` write site). Reverse on parse so a caller can pass + // the same hex an explorer shows. + let mut bytes = match hex::decode(txid_hex.trim()) { + Ok(b) if b.len() == 32 => b, + Ok(_) => { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "txid must be 32 bytes (64 hex chars)", + ) + .into_response(); + } + Err(_) => { + return handler_error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "txid is not valid hex", + ) + .into_response(); + } + }; + bytes.reverse(); + + match crate::db::get_inscription_summary_by_commit_txid(&state.pool, &bytes).await { + Ok(Some(summary)) => (StatusCode::OK, Json(summary)).into_response(), + Ok(None) => { + handler_error_response(StatusCode::NOT_FOUND, "No inscription found for this txid") + .into_response() + } + Err(e) => { + eprintln!("get_inscription_handler: db error: {}", e); + handler_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Database error while looking up inscription", + ) + .into_response() + } + } +} + /// JSON body returned by `GET /health/ready`. `failures` is empty on a /// fully ready server; each failing dependency contributes one stable /// short tag (`"db"`, `"esplora"`) so a Kuma monitor parses the cause @@ -1502,6 +1557,7 @@ struct RootEndpoints { receive: &'static str, commit: &'static str, proof: &'static str, + inscription: &'static str, health: &'static str, } @@ -1522,6 +1578,7 @@ async fn root_handler() -> impl IntoResponse { receive: "POST /api/receive", commit: "POST /api/commit", proof: "GET /api/proof/{id}", + inscription: "GET /api/inscriptions/{txid}", health: "GET /health", }, docs: "https://docs.zkcoins.app", @@ -1678,9 +1735,37 @@ async fn claim_username_handler( // before; the second writer hits `rows_affected == 0` and the // handler maps that to a 409. The post-commit insert is idempotent // — re-inserting the same `(normalized, address)` is a no-op. + // Decode signature bytes once so the claim-log row carries the + // exact signature bytes the caller submitted, regardless of the + // outcome below. + let signature_bytes = hex::decode(&request.signature).unwrap_or_default(); + + // username_claim_log helper: fire-and-forget, captures every + // outcome that reaches the in-memory / SQL layer (precheck reject, + // SQL race-loser, success). Pure-validation rejects above are + // already captured via request_log on the audit path. + let log_claim = |success: bool, reject_reason: Option<&str>| { + let entry = crate::db::UsernameClaimLogEntry { + requested_username: request.username.clone(), + normalized_username: normalized_username.clone(), + address: address_bytes.to_vec(), + signature: signature_bytes.clone(), + success, + reject_reason: reject_reason.map(|s| s.to_string()), + request_log_id: None, + }; + let pool = state.pool.clone(); + tokio::spawn(async move { + if let Err(e) = crate::db::insert_username_claim_log(&pool, &entry).await { + eprintln!("Failed to persist username_claim_log: {}", e); + } + }); + }; + if let Err(reason) = lock_or_recover(&state.username_store).precheck(&normalized_username, &address) { + log_claim(false, Some(reason)); // `precheck` returns the static collision strings the wallet // surfaces verbatim. The status is `409 CONFLICT` for either // collision variant — same shape as the SQL-layer race below. @@ -1700,6 +1785,7 @@ async fn claim_username_handler( Ok(b) => b, Err(db_err) => { eprintln!("Failed to persist username claim: {}", db_err); + log_claim(false, Some(&format!("db error: {}", db_err))); return ( StatusCode::SERVICE_UNAVAILABLE, Json(LnurlErrorResponse { @@ -1711,6 +1797,7 @@ async fn claim_username_handler( } }; if !inserted { + log_claim(false, Some("race lost on ON CONFLICT")); // Concurrent claimer won the `ON CONFLICT` race for the same // name. Surface as the same 409 a precheck collision would. return ( @@ -1725,6 +1812,8 @@ async fn claim_username_handler( lock_or_recover(&state.username_store).commit_after_db(normalized_username.clone(), address); + log_claim(true, None); + ( StatusCode::OK, Json(UsernameResponse { @@ -1862,6 +1951,7 @@ pub(crate) fn create_router(state: AppState) -> Router { .route("/api/proof/:id", get(get_proof_handler)) .route("/api/commit", post(commit_handler)) .route("/api/mint", post(mint_handler)) + .route("/api/inscriptions/:txid", get(get_inscription_handler)) .route("/api/username/claim", post(claim_username_handler)) .route( "/api/username/resolve/:username", @@ -1880,9 +1970,19 @@ pub(crate) fn create_router(state: AppState) -> Router { .route("/.well-known/lnurlp/:username", get(lnurlp_handler)) .route("/lnurl/pay/:username", get(lnurl_callback_handler)); - app.with_state(state) + // Audit middleware sits OUTSIDE `with_state` because it carries its + // own `State` extractor. Layered after CORS so the audit + // log records the final, CORS-decorated response — `Access-Control-*` + // headers and all. The `from_fn_with_state` adapter clones the + // state for every request (state itself is `Arc`-backed, so the + // clone is cheap). + app.with_state(state.clone()) .fallback(|| async { StatusCode::NOT_FOUND }) .layer(cors) + .layer(axum::middleware::from_fn_with_state( + state, + crate::audit::audit_log_middleware, + )) } #[cfg(test)] diff --git a/node/src/runtime.rs b/node/src/runtime.rs index 4b375b18..32c7a897 100644 --- a/node/src/runtime.rs +++ b/node/src/runtime.rs @@ -185,9 +185,46 @@ pub async fn start_rest_node( let app = create_router(state); + // boot_log: announce the startup event with the connected network, + // server version, listen address, and process pid. Best-effort — + // a failed boot_log insert must NOT prevent the server from + // starting (the operator would lose access to a real recovery + // path on a transient DB blip). + { + let boot_entry = crate::db::BootLogEntry { + event_type: "startup".to_string(), + message: format!( + "zkcoins-node {} starting on {} (network={})", + env!("CARGO_PKG_VERSION"), + socket_addr, + NETWORK_CONFIG.network_name, + ), + metadata: Some(serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "network": NETWORK_CONFIG.network_name, + "socket_addr": socket_addr.to_string(), + "pid": std::process::id(), + "is_mainnet": NETWORK_CONFIG.is_mainnet, + })), + }; + if let Err(e) = crate::db::insert_boot_log(&pool, &boot_entry).await { + eprintln!("Failed to persist boot_log startup event: {}", e); + } + } + println!("REST server started at {}", socket_addr); let listener = TcpListener::bind(socket_addr).await?; - axum::serve(listener, app).await?; + // `into_make_service_with_connect_info::()` exposes the + // peer's TCP socket to extractors — the audit middleware reads it + // through `ConnectInfo` and writes it to + // `request_log.remote_addr`. Without this the audit row's + // `remote_addr` column is always NULL (the default `into_make_service` + // never inserts a `ConnectInfo` extension). + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await?; Ok(()) } @@ -217,8 +254,13 @@ pub(crate) async fn broadcast_commit_and_deliver( "Broadcasting user commitment ({} bytes)", commitment_data.len() ); - if let Err(err) = - create_and_broadcast_inscription(&commitment_data, &NETWORK_CONFIG, Some(&state.pool)).await + if let Err(err) = create_and_broadcast_inscription( + &commitment_data, + crate::db::InscriptionKind::Send, + &NETWORK_CONFIG, + Some(&state.pool), + ) + .await { eprintln!("Error broadcasting commit inscription: {}", err); return crate::router::handler_error_response( diff --git a/node/src/scanner_runtime.rs b/node/src/scanner_runtime.rs index 272f3a97..b41880ca 100644 --- a/node/src/scanner_runtime.rs +++ b/node/src/scanner_runtime.rs @@ -69,14 +69,19 @@ struct InscriptionScanner { client: AsyncClient, processed_blocks: HashSet, current_block_hash: Option, + /// Optional Postgres pool for the per-block `block_log` audit row. + /// `None` short-circuits the persistence (used by unit tests that + /// run without a DB). + pool: Option, } impl InscriptionScanner { - fn new(client: AsyncClient) -> Self { + fn new(client: AsyncClient, pool: Option) -> Self { Self { client, processed_blocks: HashSet::new(), current_block_hash: None, + pool, } } @@ -120,6 +125,8 @@ impl InscriptionScanner { } println!("Processing block: {}", current_hash); + let block_start = std::time::Instant::now(); + let mut inscription_count: i32 = 0; let txids = match self.client.get_block_txids(current_hash).await { Ok(txids) => txids, @@ -150,6 +157,7 @@ impl InscriptionScanner { match self.client.get_tx(&txid).await { Ok(Some(tx)) => { self.process_transaction(&tx, callback).await?; + inscription_count += 1; } Ok(None) => { println!("Transaction {} not found", txid); @@ -163,6 +171,27 @@ impl InscriptionScanner { self.processed_blocks.insert(current_hash); let block_status = self.client.get_block_status(¤t_hash).await?; + + // Persist a block_log row for this block: hash, height, + // inscription count, and processing duration. Fire-and- + // forget — a block_log insert failure must not break the + // scanner loop (the scanner is the only path to chain-tip + // catch-up and we never want to wedge it on a DB blip). + if let Some(pool) = &self.pool { + let block_entry = crate::db::BlockLogEntry { + block_hash: >::as_ref(¤t_hash).to_vec(), + block_height: block_status.height.map(i64::from), + inscription_count, + processing_duration_us: i64::try_from(block_start.elapsed().as_micros()).ok(), + }; + let pool = pool.clone(); + tokio::spawn(async move { + if let Err(e) = crate::db::insert_block_log(&pool, &block_entry).await { + eprintln!("Failed to persist block_log: {}", e); + } + }); + } + match block_status.next_best { Some(next_hash) => current_hash = next_hash, None => { @@ -215,12 +244,13 @@ impl InscriptionScanner { pub async fn scan_for_inscriptions( config: &EsploraConfig, start_block_hash: BlockHash, + pool: Option, callback: &InscriptionCallback, mut tip_rx: mpsc::Receiver, ) -> Result<(), Box> { let builder = Builder::new(&config.url); let client = AsyncClient::::from_builder(builder)?; - let mut scanner = InscriptionScanner::new(client); + let mut scanner = InscriptionScanner::new(client, pool); scanner .scan_from_block(start_block_hash, callback, &mut tip_rx) From 68028daede9db1e19befda1fea440f99d3578705 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 22:37:13 +0200 Subject: [PATCH 06/19] docs(program-plonky2,script-plonky2,shared): replace remaining "server" references Updates doc-comments, migration notes, and historical planning records in program-plonky2 (CONTRIBUTING/SESSION_STATE/STEP4_REVIEW/STEP7_PREP plus the rustdoc on types.rs, merkle helpers, and circuit/main.rs), script-plonky2 (CONTRIBUTING + lib.rs rustdoc), and the shared commitment_tests module header to refer to the zkCoins node instead of the legacy "server" wording. No code logic changes. --- program-plonky2/CONTRIBUTING.md | 4 +- program-plonky2/SESSION_STATE.md | 14 +++---- program-plonky2/STEP4_REVIEW.md | 2 +- program-plonky2/STEP7_PREP.md | 40 +++++++++---------- program-plonky2/src/circuit/main.rs | 4 +- .../src/merkle/merkle_mountain_range.rs | 4 +- .../src/merkle/sparse_merkle_tree.rs | 2 +- program-plonky2/src/types.rs | 6 +-- script-plonky2/CONTRIBUTING.md | 12 +++--- script-plonky2/src/lib.rs | 8 ++-- shared/src/commitment_tests.rs | 4 +- 11 files changed, 50 insertions(+), 50 deletions(-) diff --git a/program-plonky2/CONTRIBUTING.md b/program-plonky2/CONTRIBUTING.md index 79ef0595..88bdb665 100644 --- a/program-plonky2/CONTRIBUTING.md +++ b/program-plonky2/CONTRIBUTING.md @@ -59,7 +59,7 @@ cargo fmt --check # Lint (used by CI gate). MUST be clean before pushing. cargo clippy --all-targets -- -D warnings -# Coverage check (will become a CI gate alongside the existing server gate). +# Coverage check (will become a CI gate alongside the existing node gate). # Per ROADMAP "Definition of MVP", 100% coverage on the activated surface # is non-negotiable. Run this before opening any PR that adds new code: cargo +nightly-2025-04-15 install cargo-llvm-cov # one-time @@ -68,7 +68,7 @@ cargo llvm-cov --fail-under-lines 100 -- --test-threads=1 ## Coverage gate -Same standard as `program/` and `server/` in the parent workspace: +Same standard as `program/` and `node/` in the parent workspace: **100% line coverage on the activated surface**. The "activated surface" is everything compiled in by default features — i.e. the entire crate at the moment, since `program-plonky2` has no feature gates yet. diff --git a/program-plonky2/SESSION_STATE.md b/program-plonky2/SESSION_STATE.md index cb05beed..7143d463 100644 --- a/program-plonky2/SESSION_STATE.md +++ b/program-plonky2/SESSION_STATE.md @@ -34,9 +34,9 @@ Tests, Analyze rust, Analyze actions, CodeQL, Coverage MVP scope). end-state documented in [`MIGRATION_RESEARCH.md` §7.22](../MIGRATION_RESEARCH.md#722-stage-5d-next-5-source-side-verification-via-aggregator-pattern--codified-resolves-721). - Step 6 (script-plonky2 prover host wrapper): ✅ done (`d96bb62`) -- Step 7 (server replacement): ✅ done. Workspace toolchain unified +- Step 7 (node replacement): ✅ done. Workspace toolchain unified to nightly. `program/` + `script/` deleted (recoverable via - `git checkout v0.last-sp1 -- ...`). shared + server fully + `git checkout v0.last-sp1 -- ...`). shared + node fully migrated to Plonky2-era modules with the HashDigest type-shift handled at all boundaries. `account_node::send_coins` wired to the Plonky2 `Prover` wrapper (`c71c9fc`); the **in-circuit @@ -44,7 +44,7 @@ Tests, Analyze rust, Analyze actions, CodeQL, Coverage MVP scope). through (Step 7 follow-up, addresses #25), with the off-circuit pre-check loop retained as **defense-in-depth fast-fail** before the minute-scale prove. Dockerfile re-introduced (`dac0179`). 138 - server tests pass with `--all-features` (32 baseline + 10 inline + node tests pass with `--all-features` (32 baseline + 10 inline error-path in `d6a3cb9` + 64 ported SP1-era fixtures re-enabled via `account_node_tests.rs` + `router_tests.rs` + 13 feature-gated + 1 new Stage 5d-next-5 Phase 2b negative + 17 @@ -59,7 +59,7 @@ Tests, Analyze rust, Analyze actions, CodeQL, Coverage MVP scope). `cargo run --release -p node` boots cleanly: - `Prover::new()` builds the cyclic state-transition circuit -- REST server binds `0.0.0.0:4242` +- REST API binds `0.0.0.0:4242` - `GET /health` → `ok` - `GET /api/info` → `{"network":"Mutinynet"}` - Block scanner connects to Esplora + processes Mutinynet tip @@ -157,7 +157,7 @@ At Stage 5d-next-5 / Phase 2b production parameters exercise off-circuit gadgets (Poseidon / SMT / MMR / types / inputs). `cargo test --release --lib -- --test-threads=2` wall ~42 min on M3. Single-threaded ~80–120 min on `ubuntu-latest`. -- `server` crate: 120 tests with `--all-features` (32 baseline + 10 +- `node` crate: 120 tests with `--all-features` (32 baseline + 10 inline error-path + 64 ported SP1-era fixtures + 13 feature-gated + 1 Stage 5d-next-5 Phase 2b negative). `cargo test -p node --release --all-features -- --test-threads=1` wall ~36 min on M3. @@ -220,7 +220,7 @@ likely to be touched next" above. - Pre-mainnet protocol redesigns (D2/D10 / D7 / D8 — see ROADMAP "Pre-mainnet blockers"). -Step 6 (`script-plonky2/` prover host) and Step 7 (server-side +Step 6 (`script-plonky2/` prover host) and Step 7 (node-side replacement + in-circuit `send_coins` follow-up) have BOTH landed on this branch. @@ -240,7 +240,7 @@ Kept for the wall-time reference points; the current branch is at **Current branch (Stage 5d-next-5 / Phase 2b landed; PR #31 housekeeping merged).** Full `program-plonky2` lib sweep ~42 min wall on M3 with `--test-threads=2`, 115 cyclic-recursion tests -green; full server sweep `cargo test -p node --release +green; full node sweep `cargo test -p node --release --all-features -- --test-threads=1` ~36 min wall, 138 tests green (including the Phase 2b negative `test_send_coins_rejects_tampered_source_proof_inclusion` + the diff --git a/program-plonky2/STEP4_REVIEW.md b/program-plonky2/STEP4_REVIEW.md index 8978a8b9..23981782 100644 --- a/program-plonky2/STEP4_REVIEW.md +++ b/program-plonky2/STEP4_REVIEW.md @@ -72,7 +72,7 @@ If two keys differ only at the very last bit (bit 255), `combined_len = 256` and **Where:** `program-plonky2/src/circuit/smt.rs`, ~line 676 (inside `#[cfg(test)] mod tests`). -**Observation:** the helper that mirrors the off-circuit `NonInclusionProof::insert` padding loop and produces the `extension` siblings vector is currently inside the test module. The monolithic circuit (Step 5) and the eventual server prover wiring (Step 7) will need exactly this logic on the host side. +**Observation:** the helper that mirrors the off-circuit `NonInclusionProof::insert` padding loop and produces the `extension` siblings vector is currently inside the test module. The monolithic circuit (Step 5) and the eventual node prover wiring (Step 7) will need exactly this logic on the host side. **Why not a bug:** tests pass. The helper is local to the test module by design; nothing depends on it externally yet. diff --git a/program-plonky2/STEP7_PREP.md b/program-plonky2/STEP7_PREP.md index f998eb6e..ceb1c1fd 100644 --- a/program-plonky2/STEP7_PREP.md +++ b/program-plonky2/STEP7_PREP.md @@ -1,8 +1,8 @@ -# Step 7 Prep — SP1 → Plonky2 Server Cutover Inventory +# Step 7 Prep — SP1 → Plonky2 Node Cutover Inventory > **✅ STATUS — Step 7 is DONE.** This file is kept as the historical > planning record. The actual cutover landed across commits `00adbb4` -> (workspace + server imports), `c71c9fc` (send_coins wired to the +> (workspace + node imports), `c71c9fc` (send_coins wired to the > Plonky2 Prover, **off-circuit source-side validation as a > placeholder while Stage 5d-next-5 Phase 2 was deferred**), > `dac0179` (Dockerfile), `d6a3cb9` (inline error-path tests), the @@ -26,7 +26,7 @@ --- -Read-only inventory of every place in the existing SP1-era server code +Read-only inventory of every place in the existing SP1-era node code that must change for **Step 7** (replace SP1 with Plonky2; no Cargo feature flag, no dual backend, no migration — see [`../CONTRIBUTING.md`](../CONTRIBUTING.md) § "Working on the Plonky2 @@ -60,7 +60,7 @@ avoid editing files Step 5 is also touching. | L201 | `previous_proof.public_values.read::()` | Same as L132 | 🧩 | | L379–380 | `bincode::deserialize::(&proof.public_values.to_vec())` | Same as L132 (no `to_vec` round trip needed if `ProofData` is already a field-element struct) | 🧩 | -### 2. `node/src/server.rs` +### 2. `node/src/router.rs` | Where | Current | What it becomes | Tag | | ----- | ------- | --------------- | --- | @@ -94,7 +94,7 @@ No SP1 references. **Zero changes** unless Step 5 changes the on-chain commitmen No SP1 references. **Zero changes.** Taproot inscription publishing is hash-agnostic. -### 7. `server/Cargo.toml` +### 7. `node/Cargo.toml` | Where | Current | What it becomes | Tag | | ----- | ------- | --------------- | --- | @@ -119,8 +119,8 @@ No SP1 references. **Zero changes.** Taproot inscription publishing is hash-agno | Where | Current | What it becomes | Tag | | ----- | ------- | --------------- | --- | -| L2–6 | `members = ["program", "script", "server", "shared"]` | `members = ["program-plonky2", "script-plonky2", "server", "shared"]` if going all-in. Alternative: keep `program` for the off-circuit types we still rely on (but they're already ported to `program-plonky2`, so this is dead). Recommendation: rename in one step. | 🔧 + ⚙ | -| L7–11 | `exclude = ["program-plonky2"]` (the nightly-toolchain workaround) | **remove the exclude** — `program-plonky2` becomes a workspace member. **But this means the whole workspace needs to support its nightly toolchain.** Two options: (i) move everything to nightly (probably safe since SP1 is being deleted), (ii) keep `program-plonky2` separate and have `server` depend on it via path-with-exclude trick. Recommendation: (i) — the SP1 reason for stable-1.81 is gone after this step. | ⚙ | +| L2–6 | `members = ["program", "script", "server", "shared"]` | `members = ["program-plonky2", "script-plonky2", "node", "shared"]` if going all-in. Alternative: keep `program` for the off-circuit types we still rely on (but they're already ported to `program-plonky2`, so this is dead). Recommendation: rename in one step. | 🔧 + ⚙ | +| L7–11 | `exclude = ["program-plonky2"]` (the nightly-toolchain workaround) | **remove the exclude** — `program-plonky2` becomes a workspace member. **But this means the whole workspace needs to support its nightly toolchain.** Two options: (i) move everything to nightly (probably safe since SP1 is being deleted), (ii) keep `program-plonky2` separate and have `node` depend on it via path-with-exclude trick. Recommendation: (i) — the SP1 reason for stable-1.81 is gone after this step. | ⚙ | | L23 | `sp1-sdk = "4.0.0"` workspace dep | **delete** | 🔧 | | L32–50 | 18× `[patch.crates-io]` SP1 patches | **delete** | 🔧 | @@ -128,7 +128,7 @@ No SP1 references. **Zero changes.** Taproot inscription publishing is hash-agno | Where | Current | What it becomes | Tag | | ----- | ------- | --------------- | --- | -| L2 | `channel = "1.81.0"` | Two options: (i) `channel = "nightly-2025-04-15"` to match `program-plonky2/rust-toolchain.toml` and unify the workspace, (ii) keep stable for `server`/`shared` if they don't need nightly features. Recommendation: (i) once SP1 is gone, the stable-pin justification is gone too. | ⚙ | +| L2 | `channel = "1.81.0"` | Two options: (i) `channel = "nightly-2025-04-15"` to match `program-plonky2/rust-toolchain.toml` and unify the workspace, (ii) keep stable for `node`/`shared` if they don't need nightly features. Recommendation: (i) once SP1 is gone, the stable-pin justification is gone too. | ⚙ | ### 12. Test infrastructure @@ -144,12 +144,12 @@ On cutover (after Step 7's image is built and ready to deploy): ```bash # On the DEV and PRD hosts: -sudo systemctl stop zkcoin-server +sudo systemctl stop zkcoin-node rm /var/lib/zkcoin/smt.bin /var/lib/zkcoin/mmr.bin /var/lib/zkcoin/mmr.bin.prev_root /var/lib/zkcoin/latest_block.bin # accounts.bin — operator's call: delete to force fresh accounts, or keep with the caveat that all stored proofs are now invalid # usernames.bin, minting_num_pubkeys.bin — fine to keep, no crypto dependency -# proofs/*.bin — delete; old proofs are SP1 format, useless to the new server -sudo systemctl start zkcoin-server +# proofs/*.bin — delete; old proofs are SP1 format, useless to the new node +sudo systemctl start zkcoin-node ``` The state-file cleanup is part of the deploy runbook, not Step 7's @@ -166,13 +166,13 @@ mismatches" below. | Category | Files affected | Effort | | -------- | -------------- | ------ | -| 🔧 Mechanical renames / import swaps | account_node.rs, server.rs, state.rs (partial), shared/{lib.rs, commitment.rs}, server/Cargo.toml, root Cargo.toml | ~45 min | -| 🧩 `HashDigest` semantic shift — `[u8;32]` → `HashOut` (NOT just a type alias swap) | account_node.rs, state.rs, server.rs, router_tests.rs (~30 call sites), shared/commitment.rs (`get_account_state_hash` return type) | ~3–4 hours | -| 🧩 Proof public-input access — `proof.public_values` (SP1) → `proof.public_inputs` (Plonky2, different element type, different deserialisation) | account_node.rs (3 sites), server.rs (1 site) | ~1 hour | -| 🛠 `ProgramInputsBuilder` doesn't exist in Plonky2 — server's `send_coins` path needs a different shape (per-slot witnesses instead of batched builder) | account_node.rs (`send_coins`) | ~2–3 hours | -| 🛠 `Prover::create_account` / `update_account` signatures differ — Plonky2 wrapper uses `prove_initial_with_in_coins` / `prove_account_update_with_in_coins`. Server needs adapter | account_node.rs, server.rs | ~1 hour | +| 🔧 Mechanical renames / import swaps | account_node.rs, router.rs, state.rs (partial), shared/{lib.rs, commitment.rs}, node/Cargo.toml, root Cargo.toml | ~45 min | +| 🧩 `HashDigest` semantic shift — `[u8;32]` → `HashOut` (NOT just a type alias swap) | account_node.rs, state.rs, router.rs, router_tests.rs (~30 call sites), shared/commitment.rs (`get_account_state_hash` return type) | ~3–4 hours | +| 🧩 Proof public-input access — `proof.public_values` (SP1) → `proof.public_inputs` (Plonky2, different element type, different deserialisation) | account_node.rs (3 sites), router.rs (1 site) | ~1 hour | +| 🛠 `ProgramInputsBuilder` doesn't exist in Plonky2 — node's `send_coins` path needs a different shape (per-slot witnesses instead of batched builder) | account_node.rs (`send_coins`) | ~2–3 hours | +| 🛠 `Prover::create_account` / `update_account` signatures differ — Plonky2 wrapper uses `prove_initial_with_in_coins` / `prove_account_update_with_in_coins`. Node needs adapter | account_node.rs, router.rs | ~1 hour | | 🛠 Persistence helpers (`save_merkle_tree` / `load_merkle_tree` / `save_mmr` / `load_mmr`) | **DONE** in commit `b76bd39` | ✅ | -| ⚙ Workspace toolchain unification: stable→nightly (entire workspace) | root rust-toolchain, all member Cargo.toml | ~1 hour to migrate + verify shared/server build on nightly | +| ⚙ Workspace toolchain unification: stable→nightly (entire workspace) | root rust-toolchain, all member Cargo.toml | ~1 hour to migrate + verify shared/node build on nightly | | ⚙ MMR leaf hash decision — SHA256 vs Poseidon | state.rs (L66–71) | confirmed Poseidon per arch invariant; ~30 min implement | | ⚙ `script/` crate deletion | repo cleanup | ~15 min | | Test infrastructure: ~25 `hex::encode(MINTING_ADDRESS)` calls now need `digest_to_bytes(&MINTING_ADDRESS)` first | router_tests.rs, account_node_tests.rs | ~1 hour | @@ -207,13 +207,13 @@ reverted to keep the repo buildable): 3. **`ProgramInputsBuilder` (SP1) has no Plonky2 analogue.** SP1 batched all inputs into a single struct passed to the prover; the Plonky2 monolithic circuit uses per-slot witnesses - (`InCoinSlotTargets`). The server's `send_coins` path must + (`InCoinSlotTargets`). The node's `send_coins` path must restructure from "build inputs → call create/update" to "construct in_coins tuples → call prove_initial_with_in_coins". 4. **`Prover::create_account` / `update_account`** are SP1-specific method names; the Plonky2 wrapper uses `prove_initial`/`prove_initial_with_in_coins` etc. Either rename - wrapper methods or rewrite server call sites. + wrapper methods or rewrite node call sites. 5. **`HASH_SIZE` constant** (SP1: `pub const HASH_SIZE: usize = 32;`) not present in program-plonky2. Add as `pub const HASH_SIZE: usize = 32;` in `hash` module or update callers to literal `32` / @@ -245,7 +245,7 @@ The following Step 7 items become fully concrete only after Step 5 lands: 2. **`script/` crate fate:** keep as compat shim or delete? **Recommendation:** delete entirely. No external callers; the closed-test-env invariant says replace, not preserve. -3. **Workspace toolchain unification:** keep `rust-toolchain` stable for the `server`/`shared` crates, or move everything to nightly to match `program-plonky2`? **Recommendation:** move everything to nightly (SP1's stable-pin reason is gone after this step), but verify nothing in `server`/`shared` breaks on nightly first. +3. **Workspace toolchain unification:** keep `rust-toolchain` stable for the `node`/`shared` crates, or move everything to nightly to match `program-plonky2`? **Recommendation:** move everything to nightly (SP1's stable-pin reason is gone after this step), but verify nothing in `node`/`shared` breaks on nightly first. These three decisions are not blockers for starting Step 7 work — they just need to be settled before the PR is opened for review. diff --git a/program-plonky2/src/circuit/main.rs b/program-plonky2/src/circuit/main.rs index 7ad9b8a3..44fd1b6b 100644 --- a/program-plonky2/src/circuit/main.rs +++ b/program-plonky2/src/circuit/main.rs @@ -2457,7 +2457,7 @@ mod tests { /// Build a `CommitmentMerkleProofs` witness for an Initial → AccountUpdate /// chain on the same account state. /// - /// The off-circuit setup mirrors what the server scanner would do: + /// The off-circuit setup mirrors what the node scanner would do: /// 1. Build the commitment value `c = h(asth || ocr)` for the prev proof. /// 2. Build the SMT containing `(pk_hash → c)`. /// 3. Fold the SMT root into the history MMR alongside the empty prev @@ -2523,7 +2523,7 @@ mod tests { // Bootstrap pattern: Init commits to the EMPTY history // (`prev.commitment_history_root == ZERO_HASH`); after Init the - // server folds its commitment into the MMR, giving the + // node folds its commitment into the MMR, giving the // post-fold `history_root_extended` against which Update is // proved. The fixture matches that exact layout — (e)'s leaf // shape `h(smt_root || ZERO_HASH)` coincides with (d)'s leaf. diff --git a/program-plonky2/src/merkle/merkle_mountain_range.rs b/program-plonky2/src/merkle/merkle_mountain_range.rs index e9b56a6e..5ba406fc 100644 --- a/program-plonky2/src/merkle/merkle_mountain_range.rs +++ b/program-plonky2/src/merkle/merkle_mountain_range.rs @@ -22,7 +22,7 @@ pub type MerklePath = Vec; /// [`MerkleMountainRange::root_extended`] and [`MMRProof::extend_to`] /// before being consumed in-circuit. /// -/// Picked so a single zkCoins server can run for many years of state +/// Picked so a single zkCoins node can run for many years of state /// transitions without exhausting the MMR; the closed test env makes /// this a free parameter (no on-chain commitment to a specific depth). pub const MMR_MAX_DEPTH: usize = 32; @@ -221,7 +221,7 @@ impl MerkleMountainRange { /// Persist a `MerkleMountainRange` to `path` via bincode. Matches /// the SP1-era `zkcoins_program::merkle::merkle_mountain_range` -/// helper shape — used by the server's `State::save_to_files` cutover. +/// helper shape — used by the node's `State::save_to_files` cutover. pub fn save_mmr(mmr: &MerkleMountainRange, path: &str) -> std::io::Result<()> { use std::io::Write; let file = std::fs::File::create(path)?; diff --git a/program-plonky2/src/merkle/sparse_merkle_tree.rs b/program-plonky2/src/merkle/sparse_merkle_tree.rs index 2fc0a164..48042991 100644 --- a/program-plonky2/src/merkle/sparse_merkle_tree.rs +++ b/program-plonky2/src/merkle/sparse_merkle_tree.rs @@ -325,7 +325,7 @@ impl SparseMerkleTree { /// Persist a `SparseMerkleTree` to `path` via bincode. Matches the /// SP1-era `zkcoins_program::merkle::sparse_merkle_tree::save_merkle_tree` -/// shape — used by the server's `State::save_to_files` cutover. +/// shape — used by the node's `State::save_to_files` cutover. pub fn save_merkle_tree(tree: &SparseMerkleTree, path: &str) -> std::io::Result<()> { use std::io::Write; let file = std::fs::File::create(path)?; diff --git a/program-plonky2/src/types.rs b/program-plonky2/src/types.rs index 00e6b151..6a9d4bed 100644 --- a/program-plonky2/src/types.rs +++ b/program-plonky2/src/types.rs @@ -25,9 +25,9 @@ pub type PublicKey = [u8; 33]; pub type Address = HashDigest; /// Minting account address. Currently a placeholder derived from a -/// domain-separated tag — the server will replace this with the actual +/// domain-separated tag — the node will replace this with the actual /// Poseidon hash of the live minting public key as part of ROADMAP step 7 -/// ("Server: replace SP1 with Plonky2"). See SPEC.md §12.1 and divergence +/// ("Node: replace SP1 with Plonky2"). See SPEC.md §12.1 and divergence /// D11 in MIGRATION_RESEARCH.md §3. pub static MINTING_ADDRESS: std::sync::LazyLock = std::sync::LazyLock::new(|| hash_bytes(b"zkcoins:minting-address:placeholder:v1")); @@ -339,7 +339,7 @@ mod tests { #[test] fn minting_address_is_stable() { - // The placeholder MUST stay deterministic across calls; the server + // The placeholder MUST stay deterministic across calls; the node // wiring will replace this with the real Poseidon hash of the live // minting public key (see D11 in MIGRATION_RESEARCH.md). assert_eq!(*MINTING_ADDRESS, *MINTING_ADDRESS); diff --git a/script-plonky2/CONTRIBUTING.md b/script-plonky2/CONTRIBUTING.md index 3d29a413..02fd6902 100644 --- a/script-plonky2/CONTRIBUTING.md +++ b/script-plonky2/CONTRIBUTING.md @@ -3,7 +3,7 @@ Companion crate to `program-plonky2/` providing a high-level [`Prover`] struct around the low-level `zkcoins_program_plonky2::circuit::main::prove_*` API. Mirrors the -shape of the SP1-era `script/` crate so server-side integration +shape of the SP1-era `script/` crate so node-side integration follows the same pattern. ## Why a separate crate? @@ -14,20 +14,20 @@ Two reasons: (`feature(specialization)`). Both `program-plonky2/` and `script-plonky2/` use a shared nightly toolchain via the `rust-toolchain.toml` symlink. The parent stable workspace - (server, SP1-era crates) cannot directly depend on either. + (node, SP1-era crates) cannot directly depend on either. 2. **Separation of concerns.** `program-plonky2/` builds the cyclic state-transition circuit and exposes the raw `prove_*` / `verify` APIs. `script-plonky2/` wraps them in a `Prover` that owns the built circuit, so successive proofs amortise the build - cost. Server code wires against the `Prover` API. + cost. Node code wires against the `Prover` API. ## How to call this from the stable workspace -Two options for the upcoming step-7 server replacement: +Two options for the upcoming step-7 node replacement: - **Option A: subprocess boundary.** Add a `[[bin]]` target to `script-plonky2/` that takes JSON input on stdin and emits proof - bytes on stdout. The stable-workspace `server/` crate spawns it via + bytes on stdout. The stable-workspace `node/` crate spawns it via `tokio::process`. Keeps toolchain isolation but pays IPC overhead per proof (~10–100 ms serialisation, negligible against ~5–15 min proof time). @@ -62,7 +62,7 @@ underlying APIs end-to-end. The hard correctness coverage lives in - The `ProgramInputs` builder that the SP1-era `script/` crate uses. Plonky2's cyclic recursion threads its inputs slot-by-slot (`InCoinSlotTargets` / `OutCoinSlotTargets` per-slot witnesses) - instead of the SP1-era batched `ProgramInputs`. The server can + instead of the SP1-era batched `ProgramInputs`. The node can construct slot tuples directly without an intermediate builder. - CLI / RPC plumbing for Option A above. Add a `[[bin]]` target if the step-7 ROADMAP entry picks subprocess boundary. diff --git a/script-plonky2/src/lib.rs b/script-plonky2/src/lib.rs index c8d100fa..e459be4c 100644 --- a/script-plonky2/src/lib.rs +++ b/script-plonky2/src/lib.rs @@ -4,7 +4,7 @@ //! ## Architecture //! //! - [`Prover`] owns the heavy `StateTransitionCircuit` build (one -//! per process — typically created at server startup). +//! per process — typically created at node startup). //! - [`Prover::prove_initial`] / [`Prover::prove_account_update`] are //! thin convenience wrappers over the low-level //! [`zkcoins_program_plonky2::circuit::main`] APIs that thread @@ -20,7 +20,7 @@ //! This crate inherits its nightly toolchain from //! [`program-plonky2/rust-toolchain.toml`](../program-plonky2/rust-toolchain.toml) //! via a symlink — Plonky2 requires `feature(specialization)`. -//! Callers from stable-toolchain crates (e.g. the SP1-era `server/` +//! Callers from stable-toolchain crates (e.g. the SP1-era `node/` //! crate) must invoke this via a subprocess boundary (a `[[bin]]` //! target ships in a future iteration). @@ -42,7 +42,7 @@ use zkcoins_program_plonky2::merkle::sparse_merkle_tree::NonInclusionProof; use zkcoins_program_plonky2::types::{AccountState, Coin, PublicKey}; use zkcoins_program_plonky2::{C, D, F}; -// Re-export so server callers don't have to depend on +// Re-export so node callers don't have to depend on // `zkcoins-program-plonky2` directly for the source-witness type. pub use zkcoins_program_plonky2::circuit::main::InCoinSourceWitness; @@ -56,7 +56,7 @@ pub type Proof = ProofWithPublicInputs; /// /// The circuit is cyclic — its `verifier_data.circuit_digest` is /// pinned in every proof's public inputs, enforcing that all proofs -/// the server emits are verifiable by the SAME circuit instance. +/// the node emits are verifiable by the SAME circuit instance. pub struct Prover { pub circuit: StateTransitionCircuit, } diff --git a/shared/src/commitment_tests.rs b/shared/src/commitment_tests.rs index cf07a54d..31c727c9 100644 --- a/shared/src/commitment_tests.rs +++ b/shared/src/commitment_tests.rs @@ -1,8 +1,8 @@ //! Negative-path tests for the BIP-340 Schnorr `Commitment`. //! //! `Commitment::verify` is security-critical: it gates whether a signed -//! account state will be accepted by the server. These tests exercise it -//! in isolation (no server, no SMT) and pin down the boundaries between +//! account state will be accepted by the node. These tests exercise it +//! in isolation (no node, no SMT) and pin down the boundaries between //! the "raw 32-byte digest" code path and the "SHA256-hashed message" //! code path inside `Commitment::new` / `Commitment::verify`. From b53ee42b3e03c691cf462c30f30d46cde7a9f86b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 22:50:22 +0200 Subject: [PATCH 07/19] refactor(node): rename internal server-named bindings to node Sweeps the remaining "server" prose in node/src/* and node/tests/* to match the post-rename crate identity. Renames three local Rust bindings whose names referenced the old crate: * minting_server_account -> minting_node_account (runtime.rs) * server_clone -> node_clone (router_tests.rs) * server_guard -> account_node_guard (router_tests.rs) Doc-comments and error/log messages on main.rs, router.rs, runtime.rs, scanner.rs, db.rs, account_node.rs, the matching test files, and node/tests/api_remote.rs now refer to "the node" or "the API" as appropriate. The env-var ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER is kept verbatim because it is a stable CI contract with deploy-dev.yaml. External names are untouched: StatusCode::INTERNAL_SERVER_ERROR, wiremock::MockServer + every mock_server/mint_broadcast_mock_server binding, scanner_ws_tests' spawn_ws_server (Esplora WS mock), and the bitcoind server=1 documentation example all keep their original spelling. --- node/Cargo.toml | 4 +-- node/src/account_node.rs | 4 +-- node/src/account_node_tests.rs | 6 ++-- node/src/db.rs | 4 +-- node/src/db_tests.rs | 4 +-- node/src/lib.rs | 2 +- node/src/main.rs | 8 ++--- node/src/router.rs | 22 ++++++------- node/src/router_tests.rs | 40 +++++++++++------------ node/src/runtime.rs | 10 +++--- node/src/runtime_tests.rs | 2 +- node/src/scanner.rs | 2 +- node/src/scanner_tests.rs | 2 +- node/src/scanner_ws.rs | 2 +- node/src/state_tests.rs | 2 +- node/tests/api_remote.rs | 59 ++++++++++++++++++---------------- 16 files changed, 88 insertions(+), 85 deletions(-) diff --git a/node/Cargo.toml b/node/Cargo.toml index 3b581a05..8392aa2b 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -69,12 +69,12 @@ wiremock = "0.6" testcontainers = "0.27" testcontainers-modules = { version = "0.15", features = ["postgres"] } # HTTP client for the `api_remote` integration test, which exercises -# the deployed DEV server end-to-end. rustls (not native-tls) to keep +# the deployed DEV node end-to-end. rustls (not native-tls) to keep # the test runner self-contained on CI hosts without openssl headers. reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } # Random key + suffix generation for the `api_remote` suite so each # run picks a fresh wallet and avoids collisions with concurrent -# DEV-server consumers. +# DEV-node consumers. rand = "0.8" # Auto-cleaning scratch directories for the ProofStore tests in # `router_tests`. Replaces the ad-hoc `std::env::temp_dir() + nanos diff --git a/node/src/account_node.rs b/node/src/account_node.rs index 888f5e2d..3e1fcbd0 100644 --- a/node/src/account_node.rs +++ b/node/src/account_node.rs @@ -147,10 +147,10 @@ impl AccountNode { // TODO: Move to client. /// /// Test-only after PR-A3 — the production bootstrap rehydrates the - /// server from Postgres via `load_from_pg`, never `new`. Kept + /// node from Postgres via `load_from_pg`, never `new`. Kept /// because every test in `account_node_tests.rs`, /// `router_tests.rs`, and `runtime_tests.rs` uses it to - /// build a known-empty server before importing fixture accounts. + /// build a known-empty node before importing fixture accounts. #[cfg_attr(not(test), allow(dead_code))] pub fn new(state: Arc>) -> Self { let accounts = HashMap::new(); diff --git a/node/src/account_node_tests.rs b/node/src/account_node_tests.rs index 1666d27a..743d5512 100644 --- a/node/src/account_node_tests.rs +++ b/node/src/account_node_tests.rs @@ -392,7 +392,7 @@ fn test_receive_updates_balance() { ); } -/// Reproduces the exact configuration of /api/mint on the live DEV server: +/// Reproduces the exact configuration of /api/mint on the live DEV node: /// recipient = raw [1u8; 32] bytes, amount = 1. #[test] fn test_mint_repro_live_setup() { @@ -756,7 +756,7 @@ fn test_receive_coin_rejects_replay_via_coin_history() { /// Construction: do a real mint → recipient receive flow so that /// the recipient's `account.coin_queue[0]` carries an HONEST /// `inclusion_proof` produced by `out_coins_tree.generate_inclusion_proof`. -/// Then reach into the server's internal `accounts` map and flip +/// Then reach into the node's internal `accounts` map and flip /// one sibling on the queued entry's `inclusion_proof`. The next /// `send_coins` call from that recipient must surface the /// "In-coin not present in source's output_coins_root" error. @@ -802,7 +802,7 @@ fn test_send_coins_rejects_tampered_source_proof_inclusion() { .expect("recipient receive_coin"); // Tamper the queued `inclusion_proof.siblings[0]` directly on the - // server's internal `accounts` map. The honest off-circuit + // node's internal `accounts` map. The honest off-circuit // `source_inclusion.verify` walks the path siblings; flipping // the topmost sibling produces a recomputed root that doesn't // match the source's committed `output_coins_root`. diff --git a/node/src/db.rs b/node/src/db.rs index e71de99f..7a8dfe41 100644 --- a/node/src/db.rs +++ b/node/src/db.rs @@ -1,4 +1,4 @@ -// Postgres state-layer for the zkCoins server. +// Postgres state-layer for the zkCoins node. // // Introduced in PR-A1 of the 3-PR Postgres migration series; the // schema (see `node/migrations/*.sql`) and the typed API around @@ -486,7 +486,7 @@ pub async fn update_pending_status( /// Look up the current `status` value for a `pending_inscriptions` row /// keyed by its `commit_txid`. Returns `Ok(None)` when no row exists -/// (an external inscription that never went through this server's mint +/// (an external inscription that never went through this node's mint /// flow, e.g. an out-of-band manual recovery via the `recover_inscription` /// CLI in PR #106, or a fresh database). /// diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs index 7d4f5bb7..2a6076a9 100644 --- a/node/src/db_tests.rs +++ b/node/src/db_tests.rs @@ -5,7 +5,7 @@ // the simplest model — no shared state, no `truncate_all` ordering, // no risk of cross-test contamination. The container boot is ~3-5 s // each and the suite runs single-threaded under -// `--test-threads=1` (mirrors the rest of the server test gate), so +// `--test-threads=1` (mirrors the rest of the node test gate), so // the total wall time stays comfortably below a minute even with the // per-test container. // @@ -428,7 +428,7 @@ async fn connect_and_migrate_propagates_migration_failure() { #[tokio::test] async fn pending_inscription_status_by_commit_txid_returns_none_for_unknown_txid() { // Scanner's pre-state.update lookup: an external / out-of-band - // inscription (not produced by this server's mint flow) has no + // inscription (not produced by this node's mint flow) has no // `pending_inscriptions` row. The helper must return `None` so the // scanner falls through to its normal state.update path instead of // short-circuiting. diff --git a/node/src/lib.rs b/node/src/lib.rs index a3f43208..ec3915b0 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -1,6 +1,6 @@ //! Library crate root for `node`. //! -//! The server is primarily a binary (`main.rs`), but a few pieces of +//! The node is primarily a binary (`main.rs`), but a few pieces of //! it must be reachable from out-of-tree integration tests //! (`node/tests/api_remote.rs` in particular). Exposing those //! modules through a `lib` target keeps the binary side of the crate diff --git a/node/src/main.rs b/node/src/main.rs index 2a5b4839..d671af09 100644 --- a/node/src/main.rs +++ b/node/src/main.rs @@ -22,7 +22,7 @@ use std::error::Error as StdError; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; -// Postgres state-layer carries every persistent slice of server state +// Postgres state-layer carries every persistent slice of node state // after PR-A3: SMT / MMR / latest_block (PR-A2), accounts + usernames // (PR-A3), and the minting account's `minting_meta.num_pubkeys` counter // (PR-A3). The `accounts.bin`, `usernames.bin`, and @@ -84,7 +84,7 @@ async fn main() -> Result<(), Box> { // the bootstrap (same reasoning as the State load above). let account_node = account_node::AccountNode::load_from_pg(Arc::clone(&state), &pool) .await - .expect("load account server from Postgres"); + .expect("load account node from Postgres"); println!("Loaded AccountNode from Postgres"); let username_store = username::UsernameStore::load_from_pg(&pool) .await @@ -110,7 +110,7 @@ async fn main() -> Result<(), Box> { ) .await { - eprintln!("Account server error: {}", e); + eprintln!("Account node error: {}", e); std::process::exit(1); } }); @@ -224,7 +224,7 @@ async fn main() -> Result<(), Box> { // best-effort and we never want a single bad commitment // (replay, client bug, or a re-scan after crash where // the SMT already has this public_key with a different - // leaf value) to take the whole REST server down. The + // leaf value) to take the whole REST API down. The // scanner advances to the next block regardless. eprintln!( "Skipping commitment for public_key {}: state.update failed: {}", diff --git a/node/src/router.rs b/node/src/router.rs index 8247c37b..c4ccf9f7 100644 --- a/node/src/router.rs +++ b/node/src/router.rs @@ -174,7 +174,7 @@ pub struct ReceiveCoinRequest { coin_proof: Proof, } -/// Persistent proof store — survives server restarts. +/// Persistent proof store — survives node restarts. /// Each proof is stored as an individual file: /data/proofs/{id}.bin pub(crate) struct ProofStore { dir: String, @@ -209,7 +209,7 @@ impl ProofStore { } /// Build a safe file path for a proof ID within the store directory. - /// The ID is always a server-generated u64 and the suffix is the + /// The ID is always a node-generated u64 and the suffix is the /// literal ".bin", so `base.join(...)` cannot escape `base` — no /// extra starts_with check is needed. fn proof_path(&self, id: u64) -> Option { @@ -296,7 +296,7 @@ pub struct SendCoinResponse { /// the minute-scale prove cost is paid; surfacing the specific /// string lets clients distinguish "fix your inclusion proof" from /// "fix your account selection". -/// - **404 NOT_FOUND** — sender address is not known to the server. +/// - **404 NOT_FOUND** — sender address is not known to the node. /// - **400 BAD_REQUEST** — request structure violates the API contract /// (e.g. AccountUpdate transition without `prev_commitment_pubkey`). /// - **500 INTERNAL_SERVER_ERROR** — the prover failed. Body collapses @@ -314,7 +314,7 @@ pub(crate) fn map_send_coins_error(err: &str) -> (StatusCode, &'static str) { // `get_merkle_proofs` failures — reachable from `send_coins` // via the `prev_commitment_pubkey` path. The client supplied // the wrong public key, or the previous proof references a - // history root the server hasn't seen yet (stale snapshot). + // history root the node hasn't seen yet (stale snapshot). // Both are caller-fixable, hence 422 rather than 500. "Unable to get merkle proofs for provided public key" => ( StatusCode::UNPROCESSABLE_ENTITY, @@ -433,16 +433,16 @@ pub struct CommitRequest { pub struct InfoResponse { network: String, capabilities: Capabilities, - /// External hostname this server serves, used by the client to render + /// External hostname this node serves, used by the client to render /// `@`. DEV and PRD share the chain identifier /// but live behind different external hostnames, so the client cannot - /// derive this from `network` alone — the server reports it directly. + /// derive this from `network` alone — the node reports it directly. username_domain: String, } -/// Server-side feature gates exposed to clients so the app can render +/// Node-side feature gates exposed to clients so the app can render /// capability-driven UI without a parallel build-time env-flag set. -/// Each bool reflects a compile-time Cargo feature on the server binary, +/// Each bool reflects a compile-time Cargo feature on the node binary, /// except `faucet`: mint is part of the MVP and is always available, so /// the field is hardcoded `true`. It is kept on the struct for API /// back-compat with wallet clients that introspect `/api/info`. @@ -1284,7 +1284,7 @@ async fn get_proof_handler( /// /// **Broadcast-then-deliver invariant (zk-coins/node#89).** Unlike /// the mint flow, the `/api/commit` endpoint receives a *proof_id* the -/// server already generated (in an earlier `/api/send` call), looks up +/// node already generated (in an earlier `/api/send` call), looks up /// the persisted `CoinProof`, broadcasts its commitment, and only then /// hands the proof to `receive_coin` for the recipient mutation. The /// in-memory mutation lives in [`broadcast_commit_and_deliver`] in @@ -1351,7 +1351,7 @@ async fn commit_handler( } /// JSON body returned by `GET /health/ready`. `failures` is empty on a -/// fully ready server; each failing dependency contributes one stable +/// fully ready node; each failing dependency contributes one stable /// short tag (`"db"`, `"esplora"`) so a Kuma monitor parses the cause /// without having to scrape the status code in isolation. #[derive(Serialize)] @@ -1613,7 +1613,7 @@ async fn claim_username_handler( // Verify Schnorr signature over sha256("zkcoins:claim_username" || address_hex || normalised_username || timestamp_le). // The wallet MUST sign over the lowercase form (same normalisation // as `UsernameStore::validate`) — otherwise the same input that the - // server persists is not what the signature commits to, opening + // node persists is not what the signature commits to, opening // the case-mismatch squat described above. let mut hasher = Sha256::new(); hasher.update(b"zkcoins:claim_username"); diff --git a/node/src/router_tests.rs b/node/src/router_tests.rs index fbcd6763..b8fd1d72 100644 --- a/node/src/router_tests.rs +++ b/node/src/router_tests.rs @@ -8,7 +8,7 @@ use crate::account_node::{Account, AccountNode}; use crate::state::State; /// Build a `PgPool` that points at nowhere — every query against it -/// fails fast with a connect error. Used by the server-handler test +/// fails fast with a connect error. Used by the node-handler test /// suite below so the handlers' persistence-side `.await` lines run /// the error branch (which mirrors the legacy file-IO best-effort /// semantics: log + continue, never fail the response). The matching @@ -975,7 +975,7 @@ async fn claim_username_mixed_case_input_normalised_before_hashing() { ); } - // Send the mixed-case form. The server normalises, hashes over + // Send the mixed-case form. The node normalises, hashes over // the lowercase form, and the signature verifies. let body = serde_json::json!({ "username": user_input, @@ -1003,9 +1003,9 @@ async fn claim_username_mixed_case_input_normalised_before_hashing() { /// Counterpart to the test above: a wallet that signs over the RAW /// mixed-case input (legacy/buggy behaviour) must be rejected by the -/// server, because the server hashes the normalised form. Without +/// node, because the node hashes the normalised form. Without /// this, the case-mismatch squat is reachable: attacker signs `"Bob"`, -/// server persists `"bob"`, the legitimate `bob` owner is locked out. +/// node persists `"bob"`, the legitimate `bob` owner is locked out. #[tokio::test] async fn claim_username_raw_case_signature_rejected() { use bitcoin::secp256k1::{Keypair, SecretKey}; @@ -1058,7 +1058,7 @@ async fn claim_username_raw_case_signature_rejected() { assert_eq!( status, StatusCode::UNAUTHORIZED, - "raw-case signature must fail; server hashes normalised form" + "raw-case signature must fail; node hashes normalised form" ); } @@ -1417,7 +1417,7 @@ async fn claim_username_invalid_signature_format_returns_422() { assert_eq!(resp.reason, "Invalid signature format"); } -/// Pool with no reachable server: `db::claim_username` returns an error +/// Pool with no reachable Postgres: `db::claim_username` returns an error /// after the in-memory `precheck` passes. The handler must map that /// onto a 503. Mirrors `claim_propagates_db_error_when_pool_is_dead` /// from `username_tests.rs`, but exercises the handler's error arm. @@ -2971,10 +2971,10 @@ fn lock_or_recover_account_node_poisoned() { // copy of lock_or_recover's poison-recovery closure. let state_arc = Arc::new(Mutex::new(State::new())); let node = Arc::new(Mutex::new(AccountNode::new(Arc::clone(&state_arc)))); - let server_clone = Arc::clone(&node); + let node_clone = Arc::clone(&node); let _ = std::thread::spawn(move || { - let _guard = server_clone.lock().unwrap(); + let _guard = node_clone.lock().unwrap(); panic!("intentional poison"); }) .join(); @@ -3046,7 +3046,7 @@ fn map_send_coins_error_unable_to_get_merkle_proofs_is_422() { #[test] fn map_send_coins_error_unable_to_get_mmr_inclusion_proof_is_422() { // Reachable from send_coins via get_merkle_proofs (account_node::236). - // Caller's previous_proof references a history root the server's MMR + // Caller's previous_proof references a history root the node's MMR // hasn't observed yet — stale snapshot, caller-fixable. let (status, body) = crate::router::map_send_coins_error( "Unable to get mmr inclusion proof for the previous root", @@ -3062,7 +3062,7 @@ fn map_send_coins_error_unable_to_get_mmr_inclusion_proof_is_422() { fn map_send_coins_error_proof_public_inputs_too_short_is_500() { // Reachable from send_coins via get_merkle_proofs (account_node::232). // The proof bytes stored against the account are too short to - // decode N_PROOF_DATA_PUBLIC_INPUTS field elements — server-side + // decode N_PROOF_DATA_PUBLIC_INPUTS field elements — node-side // corruption or version mismatch, not caller-fixable. let (status, body) = crate::router::map_send_coins_error("Proof public_inputs too short"); assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); @@ -3156,7 +3156,7 @@ fn map_send_coins_error_unknown_string_is_500_internal_error() { // A new `send_coins` error string we haven't mapped yet must NOT // accidentally surface as 200 OK / 4xx. The default arm is 500 with // a generic "internal error" body so the wallet treats it as a - // server problem and the operator finds the unmapped string in the + // node problem and the operator finds the unmapped string in the // `eprintln!` log. let (status, body) = crate::router::map_send_coins_error("a string we never added"); assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); @@ -3188,7 +3188,7 @@ async fn send_with_unknown_account_returns_404_with_error_string() { .private_key; // An address that is well-formed (hex, 32 bytes) but never claimed - // an account on the server. + // an account on the node. let account_address = "0x".to_string() + &hex::encode([0xAAu8; 32]); let recipient = "0x".to_string() + &hex::encode([1u8; 32]); let amount: u64 = 50; @@ -3549,8 +3549,8 @@ fn mint_test_state_without_minting_account() -> AppState { let state = mint_test_state(); { let mut node = state.account_node.lock().unwrap(); - // Reset to a brand-new server with no accounts at all. The - // `Arc>` inside `server` is replaced too, but the + // Reset to a brand-new node with no accounts at all. The + // `Arc>` inside `node` is replaced too, but the // shared `state_inner` is dropped on overwrite which is fine // — nothing else holds it after `mint_test_state` returns. *node = AccountNode::new(Arc::new(Mutex::new(State::new()))); @@ -3660,7 +3660,7 @@ async fn mint_insufficient_funds_returns_422() { /// no-state-advance contract that the prepare-then-commit refactor /// introduced: after a broadcast failure the in-memory /// `minting_account.num_pubkeys` MUST still be 0, the minting -/// `Account` in the server's map MUST still have an empty +/// `Account` in the node's map MUST still have an empty /// `coin_queue`, `proof = None`, and the unchanged seed balance, and /// the recipient account MUST NOT exist yet. Before this PR the /// handler had already bumped the counter + mutated the minting @@ -3678,8 +3678,8 @@ async fn mint_broadcast_failure_returns_503() { let minting_coin_queue_len_before: usize; let minting_proof_some_before: bool; { - let server_guard = state.account_node.lock().unwrap(); - let acct = server_guard + let account_node_guard = state.account_node.lock().unwrap(); + let acct = account_node_guard .get_account(&zkcoins_program::types::MINTING_ADDRESS) .expect("minting account seeded by mint_test_state"); minting_balance_before = acct.balance; @@ -3721,8 +3721,8 @@ async fn mint_broadcast_failure_returns_503() { "in-memory minting_account.num_pubkeys must NOT advance on broadcast failure (zk-coins/node#89)" ); { - let server_guard = state.account_node.lock().unwrap(); - let acct_after = server_guard + let account_node_guard = state.account_node.lock().unwrap(); + let acct_after = account_node_guard .get_account(&zkcoins_program::types::MINTING_ADDRESS) .expect("minting account still present after failed mint"); assert_eq!( @@ -3740,7 +3740,7 @@ async fn mint_broadcast_failure_returns_503() { "minting Account proof must NOT be set by a failed-broadcast mint" ); assert!( - server_guard.get_account(&recipient_addr).is_none(), + account_node_guard.get_account(&recipient_addr).is_none(), "recipient account must NOT be created when broadcast fails" ); } diff --git a/node/src/runtime.rs b/node/src/runtime.rs index 4b375b18..9c1701b2 100644 --- a/node/src/runtime.rs +++ b/node/src/runtime.rs @@ -79,7 +79,7 @@ pub async fn start_rest_node( // child pubkey for ordinary wallets; for the minting wallet that // derivation is meaningless — only the wallet's commitment-signing // side is used. Force the address to the canonical constant so - // the rest of the server (which reads minting_account.address as + // the rest of the node (which reads minting_account.address as // the on-chain identity of the minting wallet) is internally // consistent. The test harness already constructs the minting // account this way (see @@ -114,7 +114,7 @@ pub async fn start_rest_node( let bootstrap_snapshot: Option<(zkcoins_program::hash::HashDigest, Vec)> = { let mut account_node_guard = state.account_node.lock().unwrap(); if account_node_guard.get_minting_account_address().is_err() { - let mut minting_server_account = crate::account_node::Account::new(); + let mut minting_node_account = crate::account_node::Account::new(); // The Plonky2 state-transition circuit packs the running // balance as a Goldilocks field element via // `balance_hi * 2^32 + balance_lo`. Values >= p (the @@ -123,10 +123,10 @@ pub async fn start_rest_node( // which trips a "wire set twice" partition error. Stay // safely below 2^48 so the circuit-vs-witness sides agree // even after many mint operations. - minting_server_account.balance = 1u64 << 48; + minting_node_account.balance = 1u64 << 48; account_node_guard.import_account( *zkcoins_program::types::MINTING_ADDRESS, - minting_server_account, + minting_node_account, ); account_node_guard .get_account(&zkcoins_program::types::MINTING_ADDRESS) @@ -185,7 +185,7 @@ pub async fn start_rest_node( let app = create_router(state); - println!("REST server started at {}", socket_addr); + println!("REST API started at {}", socket_addr); let listener = TcpListener::bind(socket_addr).await?; axum::serve(listener, app).await?; diff --git a/node/src/runtime_tests.rs b/node/src/runtime_tests.rs index a299624d..86a87042 100644 --- a/node/src/runtime_tests.rs +++ b/node/src/runtime_tests.rs @@ -41,7 +41,7 @@ use crate::username::UsernameStore; use zkcoins_program::hash::digest_to_bytes; use zkcoins_program::types::MINTING_ADDRESS; -/// Boot a fresh `postgres:17` container, run the server migrations +/// Boot a fresh `postgres:17` container, run the node migrations /// against it, and return the live pool plus the container handle. /// Dropping the container handle tears the container down, so the /// caller keeps it alive for the duration of the test. diff --git a/node/src/scanner.rs b/node/src/scanner.rs index f0a524fa..1ea258d2 100644 --- a/node/src/scanner.rs +++ b/node/src/scanner.rs @@ -71,7 +71,7 @@ pub(crate) fn filter_marker_txids(txids: Vec, marker_bytes: &[u8]) -> Vec< /// inscriptions broadcast by `publisher::create_and_broadcast_inscription` /// pin their reveal's `input[0]` to the commit's vout 0, so the /// commit_txid surfaced here matches the `commit_txid` column in -/// `pending_inscriptions` for every inscription this server originated. +/// `pending_inscriptions` for every inscription this node originated. pub(crate) fn process_transaction_inscriptions( tx: &Transaction, current_block_hash: BlockHash, diff --git a/node/src/scanner_tests.rs b/node/src/scanner_tests.rs index b2937f39..613d5c88 100644 --- a/node/src/scanner_tests.rs +++ b/node/src/scanner_tests.rs @@ -321,7 +321,7 @@ fn should_skip_scanner_state_update_returns_true_only_for_complete() { #[test] fn should_skip_scanner_state_update_false_for_missing_row() { // Out-of-band / recovery inscription that never went through this - // server's mint flow: no `pending_inscriptions` row, scanner is the + // node's mint flow: no `pending_inscriptions` row, scanner is the // authoritative integration path. assert!(!should_skip_scanner_state_update(None)); } diff --git a/node/src/scanner_ws.rs b/node/src/scanner_ws.rs index b93a4878..e21df9bf 100644 --- a/node/src/scanner_ws.rs +++ b/node/src/scanner_ws.rs @@ -9,7 +9,7 @@ //! //! TODO(structured-logging): this module still uses `println!` / //! `eprintln!` for runtime logs, consistent with the rest of the -//! `server` crate's current conventions. Once the crate-wide +//! `node` crate's current conventions. Once the crate-wide //! migration to `tracing` lands (out of scope for issue #84), the //! reconnect / liveness lines below are the first candidates for //! structured fields (peer URL, attempt count, backoff value) since diff --git a/node/src/state_tests.rs b/node/src/state_tests.rs index eda89c4c..fa0062a5 100644 --- a/node/src/state_tests.rs +++ b/node/src/state_tests.rs @@ -148,7 +148,7 @@ async fn test_persist_and_load_state_roundtrip() { #[tokio::test] async fn test_load_from_pg_empty_returns_fresh_state() { - // No rows in smt_state / mmr_state means a fresh server: both + // No rows in smt_state / mmr_state means a fresh node: both // trees must come back empty — equivalent to State::new(). let (pool, _container) = setup_pool().await; let loaded = State::load_from_pg(&pool).await.expect("load_from_pg"); diff --git a/node/tests/api_remote.rs b/node/tests/api_remote.rs index a44d7161..ce899e6f 100644 --- a/node/tests/api_remote.rs +++ b/node/tests/api_remote.rs @@ -1,4 +1,4 @@ -//! HTTP API end-to-end test suite for the deployed zkCoins server. +//! HTTP API end-to-end test suite for the deployed zkCoins node. //! //! This suite is the functional counterpart to the smoke test inside //! `.github/workflows/deploy-dev.yaml` (which only probes `/api/info`). @@ -9,10 +9,10 @@ //! exercising the API contract happy path against the same backend //! the wallet app talks to. //! -//! Scope note: the suite verifies server-visible behaviour (status +//! Scope note: the suite verifies API-visible behaviour (status //! codes, response shapes, balance movements). The commit message //! format used in `send_commit_roundtrip_moves_balance` is the -//! 64-byte `ash || ocr` raw concat, which the server accepts via +//! 64-byte `ash || ocr` raw concat, which the node accepts via //! `Commitment::verify`'s SHA-256 fallback. The canonical wallet //! client signs the 32-byte Poseidon `hash_concat(ash, ocr)` digest //! (see `shared::ClientAccount::create_commitment`); the two forms @@ -20,11 +20,11 @@ //! and the suite never re-spends from the test wallet so the leaf //! shape is observationally indistinguishable in-scope. //! -//! The DEV server is shared by other workflows (per-PR app E2E, +//! The DEV node is shared by other workflows (per-PR app E2E, //! interactive testing). To keep this suite race-free we always: //! - mint into freshly-generated wallets (no fixed addresses) //! - assert strictly on 4xx codes (client-fixable contract bugs) -//! - assert strictly on 5xx codes as well (server-side regressions +//! - assert strictly on 5xx codes as well (node-side regressions //! are real bugs, not flakes — the deploy-dev preflight verifies //! publisher wallet + /health/ready BEFORE this suite runs, so a //! 503 here is unambiguous: it means something regressed) @@ -35,7 +35,7 @@ //! //! Configuration: //! - `ZKCOINS_API_URL` (default `https://dev-api.zkcoins.app`) — -//! the base URL of the server under test. +//! the base URL of the node under test. use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; use bitcoin::secp256k1::{self as secp, Keypair, Message, PublicKey, SecretKey}; @@ -110,26 +110,29 @@ fn url(path: &str) -> String { /// (any value, even empty) downgrades the CI panic back to a silent /// skip. The dev-api / prd-api Docker images intentionally ship the /// MVP-only feature set (`Dockerfile` `ARG FEATURES=`), so when the -/// suite runs `--all-features` against a feature-trimmed *server* +/// suite runs `--all-features` against a feature-trimmed *node* /// the gated `address_list` / `lnurl` tests must skip cleanly instead /// of panicking the CI canary. The env var documents this as an -/// opt-in: workflows that point the suite at a trimmed server set it, -/// workflows that point it at a fully-featured server leave it unset +/// opt-in: workflows that point the suite at a trimmed node set it, +/// workflows that point it at a fully-featured node leave it unset /// so the canary stays armed. +/// +/// The env-var name keeps the legacy `_SERVER` suffix as a stable +/// contract with `.github/workflows/deploy-dev.yaml`; the prose above +/// reflects the post-rename "node" terminology. macro_rules! feature_skip { ($feature:expr, $test:expr) => {{ - let allow_trimmed_server = - std::env::var("ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER").is_ok(); - if std::env::var("CI").is_ok() && !allow_trimmed_server { + let allow_trimmed_node = std::env::var("ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER").is_ok(); + if std::env::var("CI").is_ok() && !allow_trimmed_node { panic!( "feature `{}` disabled but running in CI — all-features build is required \ - (set ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER=1 if the target server is \ + (set ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER=1 if the target node is \ intentionally feature-trimmed, e.g. the MVP-only DEV image)", $feature ); } eprintln!( - "SKIP {}: feature `{}` disabled on this server", + "SKIP {}: feature `{}` disabled on this node", $test, $feature ); return; @@ -149,9 +152,9 @@ macro_rules! feature_skip { // rest of the test if the relevant feature flag is `false`. // // `ZKCOINS_FORCE_DISABLE_FEATURES` (comma-separated list, e.g. -// `address_list,lnurl`) overrides any flag returned by the server +// `address_list,lnurl`) overrides any flag returned by the node // to `false`. This is the local dry-run hook — point the suite at the -// live DEV server, force features off, and confirm that every gated +// live DEV node, force features off, and confirm that every gated // test prints `SKIP …` instead of hitting a disabled-on-paper but // actually-running endpoint. Forcing `faucet` or `usernames` off is a // no-op (the routes are always registered) and the flags are ignored. @@ -225,7 +228,7 @@ async fn fetch_capabilities(client: &reqwest::Client) -> Capabilities { // --------------------------------------------------------------------------- // TestWallet — fresh-per-test random key + helpers for signing the four -// request shapes the server accepts (send / commit / username-claim). +// request shapes the node accepts (send / commit / username-claim). // --------------------------------------------------------------------------- struct TestWallet { @@ -237,7 +240,7 @@ impl TestWallet { fn new() -> Self { let mut seed = [0u8; 32]; rand::thread_rng().fill_bytes(&mut seed); - // Signet matches the mutinynet flavour the DEV server runs on; + // Signet matches the mutinynet flavour the DEV node runs on; // the network choice only affects xpub serialisation prefixes, // not the derived secp256k1 keys we sign with. let xpriv = Xpriv::new_master(Network::Signet, &seed).expect("derive xpriv from seed"); @@ -267,7 +270,7 @@ impl TestWallet { Keypair::from_secret_key(&self.secp, &self.seckey(idx)) } - /// The hex address that the server treats as the account identifier. + /// The hex address that the node treats as the account identifier. /// Mirrors `shared::AccountState::new` → `sha256(compressed_pubkey)`. fn address_hex(&self) -> String { let pk = self.pubkey(0); @@ -297,7 +300,7 @@ impl TestWallet { /// Sign the commit message: the BIP-340 Schnorr signature is /// produced by `Commitment::new`, which SHA256s any non-32-byte - /// payload before signing. The server reconstructs the + /// payload before signing. The node reconstructs the /// `Commitment` struct from `(public_key, signature, message)` /// and re-verifies it the same way. fn sign_commit(&self, message_bytes: &[u8]) -> String { @@ -309,7 +312,7 @@ impl TestWallet { /// Sign the username-claim preimage: /// `SHA256("zkcoins:claim_username" || address_hex_str || normalised_username_str || timestamp_le8)`. /// - /// The server canonicalises the username with `to_lowercase()` + /// The node canonicalises the username with `to_lowercase()` /// before hashing; wallets must sign over the same lowercase form /// or verification fails. The helper mirrors that to keep the /// signature path honest end-to-end. @@ -523,7 +526,7 @@ async fn address_list_returns_addresses() { #[tokio::test] async fn proof_for_huge_id_returns_404() { - // u64::MAX is guaranteed to exceed any real proof_id the server + // u64::MAX is guaranteed to exceed any real proof_id the node // has issued, so the file-on-disk lookup misses and returns 404. let resp = http_client() .get(url(&format!("/api/proof/{}", u64::MAX))) @@ -678,7 +681,7 @@ async fn send_bad_address_hex_returns_422() { #[tokio::test] async fn send_unknown_account_returns_404() { // Well-formed body, valid signatures, but the sender account has - // no balance / state on the server, so `send_coins` returns + // no balance / state on the node, so `send_coins` returns // "Unknown account address" → 404. let alice = TestWallet::new(); let bob = TestWallet::new(); @@ -806,7 +809,7 @@ async fn commit_unknown_proof_id_returns_404() { #[tokio::test] async fn commit_bad_message_hex_returns_422_or_404() { let alice = TestWallet::new(); - // proof_id=1 may or may not exist on the server. If it exists, the + // proof_id=1 may or may not exist on the node. If it exists, the // handler reaches the hex-decode step and returns 422. If not, the // proof-store miss short-circuits at 404. Both are acceptable for // this negative-path coverage. @@ -901,7 +904,7 @@ async fn claim_username_stale_timestamp_returns_401() { } // --------------------------------------------------------------------------- -// Section 3 — happy-path roundtrips against the deployed server +// Section 3 — happy-path roundtrips against the deployed node // --------------------------------------------------------------------------- /// Roundtrip A — mint into a fresh wallet and observe the balance. @@ -962,7 +965,7 @@ async fn mint_roundtrip_lands_balance_and_proof() { bincode::deserialize(&proof_bytes).expect("decode CoinProof bincode"); assert!( coin_proof.commitment.is_some(), - "mint coin proof should carry a server-signed commitment" + "mint coin proof should carry a node-signed commitment" ); assert_eq!(coin_proof.coin.amount, MINT_AMOUNT); } @@ -970,7 +973,7 @@ async fn mint_roundtrip_lands_balance_and_proof() { /// Roundtrip B — full mint → send → commit pipeline. /// /// The send half requires the previous commitment's signing key as -/// `prev_commitment_pubkey`. After a mint that's the server's minting +/// `prev_commitment_pubkey`. After a mint that's the node's minting /// pubkey, embedded in the mint's `CoinProof.commitment`. #[tokio::test] async fn send_commit_roundtrip_moves_balance() { @@ -1085,7 +1088,7 @@ async fn send_commit_roundtrip_moves_balance() { // Value-bearing assertions on the response payload: each hash // field must decode to exactly 32 bytes and be non-zero. A - // shape-only `.is_some()` check was masking server bugs that + // shape-only `.is_some()` check was masking node bugs that // returned a placeholder zero-hash or a truncated hex string. let ash_hex = send_body["account_state_hash"] .as_str() From 8ad972d3afd4617f8c3749b457738d27b1b78021 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 22:52:47 +0200 Subject: [PATCH 08/19] chore: update workflow comments to use "node"/"API" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the remaining prose references to the legacy "server" name in ci.yaml, deploy-dev.yaml, and deploy-prd.yaml — workflow_dispatch descriptions, deploy-target labels ("deployed DEV/PRD server"), test-target descriptions ("live-DEV-server verification"), and the bootstrap-env comment all now refer to "the node" instead. External names stay untouched: SSH ServerAlive*, github.server_url, sccache --start-server / --stop-server, and the ZKCOINS_E2E_ALLOW_FEATURE_TRIMMED_SERVER env-var contract with the api_remote test macro all keep their original spelling. --- .github/workflows/ci.yaml | 6 +++--- .github/workflows/deploy-dev.yaml | 8 ++++---- .github/workflows/deploy-prd.yaml | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 791cbfa1..a91a3704 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -171,7 +171,7 @@ jobs: # this, runs against the public Mutinynet API can take >60 s per # test. Mirrors the pre-push hook. ESPLORA_URL: http://127.0.0.1:1/api - # `USERNAME_DOMAIN` is required by the server bootstrap (no + # `USERNAME_DOMAIN` is required by the node bootstrap (no # default — see node/src/main.rs and issue #95). The test value # is irrelevant for the `info_returns_*` assertions (they only # check non-empty + shape). @@ -265,7 +265,7 @@ jobs: # plus smart scheduling (slow tests start first). `--test-threads 1` # is preserved — the repo invariant is that tests run serially to # avoid testcontainers port races and shared-state pollution. - # `api_remote` is the live-DEV-server verification integration test + # `api_remote` is the live-DEV-node verification integration test # (node/tests/api_remote.rs). It targets `https://dev-api.zkcoins.app` # by default and is meant to run AFTER a deploy, from the `api-e2e` # job in deploy-dev.yaml — not against whatever DEV currently runs @@ -346,7 +346,7 @@ jobs: # # The `api_remote` integration test (node/tests/api_remote.rs) # is excluded for the same reason as in `node-tests` above: it - # targets the live DEV server and belongs in the post-deploy + # targets the live DEV node and belongs in the post-deploy # `api-e2e` job, not the hermetic coverage gate. The MVP coverage # scope is measured by the rest of the suite, which covers the # in-process axum handlers via oneshot(). diff --git a/.github/workflows/deploy-dev.yaml b/.github/workflows/deploy-dev.yaml index 12224d04..fa140756 100644 --- a/.github/workflows/deploy-dev.yaml +++ b/.github/workflows/deploy-dev.yaml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: reset_state: - description: 'Reset server state (clear blockchain data)' + description: 'Reset node state (clear blockchain data)' required: false type: boolean default: false @@ -126,12 +126,12 @@ jobs: echo "::error::DEV /api/info never returned 200 within ~5 min after deploy" exit 1 - # Functional verification of the deployed DEV server. + # Functional verification of the deployed DEV node. # # The smoke test in `build-and-deploy` only proves the HTTP listener # is bound; this job exercises all 15 routes end-to-end (read-only, # negative-path, full mint→send→commit and username-claim roundtrips - # against the live server). Runs on the same self-hosted M3 Ultra + # against the live node). Runs on the same self-hosted M3 Ultra # runner as `node-tests` / `coverage`, so sccache hits the warm # cache populated by previous runs and the build itself stays # well under a minute on a hot cache. @@ -144,7 +144,7 @@ jobs: RUSTC_WRAPPER: sccache ZKCOINS_API_URL: https://dev-api.zkcoins.app # The bootstrap `lazy_static`s panic if these are unset; the - # integration test only talks to the deployed server but the + # integration test only talks to the deployed node but the # lib's panic-on-load behaviour is unconditional. Values are # placeholders — nothing in the test path reads them. USERNAME_DOMAIN: dev.zkcoins.app diff --git a/.github/workflows/deploy-prd.yaml b/.github/workflows/deploy-prd.yaml index 8af19ffe..10e3faca 100644 --- a/.github/workflows/deploy-prd.yaml +++ b/.github/workflows/deploy-prd.yaml @@ -68,7 +68,7 @@ jobs: # produces no stdout for >60s. Mirrors deploy-dev.yaml; see # the comment there for the failure mode that motivated this # (PR #111 merge run 26419696840 — SSH dropped mid-recreate, - # exit 255, container actually came up server-side). + # exit 255, container actually came up node-side). ssh -i ~/.ssh/deploy_key \ -o ServerAliveInterval=30 \ -o ServerAliveCountMax=8 \ @@ -100,7 +100,7 @@ jobs: echo "::error::PRD /api/info never returned 200 within ~5 min after deploy" exit 1 - # Functional verification of the deployed PRD server. Mirrors the + # Functional verification of the deployed PRD node. Mirrors the # Deploy DEV api-e2e job, but excludes the three roundtrip tests — # they would consume real publisher UTXOs and write coins into the # production SMT/MMR. `--skip _roundtrip_` is a substring match; the @@ -115,7 +115,7 @@ jobs: RUSTC_WRAPPER: sccache ZKCOINS_API_URL: https://api.zkcoins.app # The bootstrap `lazy_static`s panic if these are unset; the - # integration test only talks to the deployed server but the + # integration test only talks to the deployed node but the # lib's panic-on-load behaviour is unconditional. Values are # placeholders — nothing in the read-only test path reads them. USERNAME_DOMAIN: zkcoins.app From 7f3f44b37198e8879bdce67f8cd9c1744102452d Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 23:07:39 +0200 Subject: [PATCH 09/19] test: drop 0010 length CHECKs in the 5 corrupt-row-plant tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0010 (round-2 polish) added BYTEA `octet_length = 32` CHECK constraints across the schema. Five existing tests deliberately plant corrupt-length rows directly via SQL to exercise the Rust-side defensive decode paths (`LoadAccountNodeError::BadAddressLength`, `sqlx::Error::Decode` from `load_latest_block` / `load_root_indices`, `UsernameStore::load_from_pg` bad-length error). With the DB-level CHECKs in place, the bad inserts get rejected before the Rust code even sees them — the test panics on the INSERT `.unwrap()` and the negative path is never exercised. Fix: drop the relevant constraint inside each per-test container before planting the bad row. Each test gets its own ephemeral testcontainers Postgres, so the drop is isolated and harmless. The constraint itself stays covered indirectly by the migration test (`connect_and_migrate` runs all migrations before the drop). Affected tests -------------- * `account_node_tests::test_load_from_pg_rejects_wrong_address_length` → DROP `accounts_address_length` * `db_tests::load_latest_block_rejects_wrong_length` (file: db_tests.rs:215) → DROP `latest_block_hash_length` * `username_tests::load_from_pg_rejects_wrong_address_length` → DROP `usernames_address_length` * `state_tests::test_load_root_indices_rejects_short_prev_root_blob` → DROP `mmr_root_index_prev_root_length` * `state_tests::test_load_root_indices_rejects_short_smt_root_blob` → DROP `mmr_root_index_smt_root_length` CI parity verified ------------------ * `cargo fmt --all --check` ✓ * `cargo clippy -p node -p shared -- -D warnings` ✓ * `cargo check -p node --tests` ✓ --- node/src/account_node_tests.rs | 13 +++++++++++++ node/src/db_tests.rs | 7 +++++++ node/src/state_tests.rs | 12 ++++++++++++ node/src/username_tests.rs | 7 +++++++ 4 files changed, 39 insertions(+) diff --git a/node/src/account_node_tests.rs b/node/src/account_node_tests.rs index 1666d27a..bac85ab0 100644 --- a/node/src/account_node_tests.rs +++ b/node/src/account_node_tests.rs @@ -559,6 +559,19 @@ async fn test_load_from_pg_rejects_wrong_address_length() { .await .expect("connect_and_migrate failed"); + // The 0010 CHECK constraint `accounts_address_length` would + // otherwise reject the wrong-length row at insert time, masking + // the actual subject of this test: the Rust-side + // `LoadAccountNodeError::BadAddressLength` defense in + // `load_from_pg`. Drop the constraint inside this per-test + // container so the corrupt-row plant succeeds; the constraint + // is itself covered indirectly by the migration test that runs + // `connect_and_migrate` here. + sqlx::query("ALTER TABLE accounts DROP CONSTRAINT accounts_address_length") + .execute(&pool) + .await + .expect("drop accounts_address_length"); + sqlx::query("INSERT INTO accounts (address, data) VALUES ($1, $2)") .bind(vec![0u8; 7]) // wrong length .bind(b"anything".to_vec()) diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs index a194dd1e..b1bf8e9c 100644 --- a/node/src/db_tests.rs +++ b/node/src/db_tests.rs @@ -213,6 +213,13 @@ async fn load_latest_block_rejects_wrong_length() { // and assert the loader returns an `sqlx::Error::Decode` rather // than panicking or silently truncating. let (pool, _container) = setup_pool().await; + // Drop the 0010 length CHECK so the corrupt-row plant succeeds; + // the subject of this test is the Rust-side defense in + // `load_latest_block`, not the DB-level CHECK. + sqlx::query("ALTER TABLE latest_block DROP CONSTRAINT latest_block_hash_length") + .execute(&pool) + .await + .expect("drop latest_block_hash_length"); sqlx::query("INSERT INTO latest_block (id, block_hash) VALUES (1, $1)") .bind(vec![0u8; 7]) .execute(&pool) diff --git a/node/src/state_tests.rs b/node/src/state_tests.rs index eda89c4c..4499c6db 100644 --- a/node/src/state_tests.rs +++ b/node/src/state_tests.rs @@ -659,6 +659,12 @@ async fn test_load_root_indices_rejects_short_prev_root_blob() { // surface as `sqlx::Error::Decode` rather than panicking on the // `try_into::<[u8; 32]>()`. let (pool, _container) = setup_pool().await; + // Drop the 0010 length CHECK so the corrupt-row plant succeeds; + // subject of this test is the Rust-side defense, not the DB CHECK. + sqlx::query("ALTER TABLE mmr_root_index DROP CONSTRAINT mmr_root_index_prev_root_length") + .execute(&pool) + .await + .expect("drop mmr_root_index_prev_root_length"); sqlx::query( "INSERT INTO mmr_root_index (prev_mmr_root, smt_root, leaf_index) \ VALUES ($1, $2, $3)", @@ -680,6 +686,12 @@ async fn test_load_root_indices_rejects_short_prev_root_blob() { async fn test_load_root_indices_rejects_short_smt_root_blob() { // Same defensive branch, for the `smt_root` column. let (pool, _container) = setup_pool().await; + // Drop the 0010 length CHECK so the corrupt-row plant succeeds; + // subject of this test is the Rust-side defense, not the DB CHECK. + sqlx::query("ALTER TABLE mmr_root_index DROP CONSTRAINT mmr_root_index_smt_root_length") + .execute(&pool) + .await + .expect("drop mmr_root_index_smt_root_length"); sqlx::query( "INSERT INTO mmr_root_index (prev_mmr_root, smt_root, leaf_index) \ VALUES ($1, $2, $3)", diff --git a/node/src/username_tests.rs b/node/src/username_tests.rs index ac5407af..4060d7f4 100644 --- a/node/src/username_tests.rs +++ b/node/src/username_tests.rs @@ -195,6 +195,13 @@ async fn load_from_pg_rejects_wrong_address_length() { // surface the mismatch as a typed error rather than panic on the // try_into. let (pool, _container) = setup_pool().await; + // Drop the 0010 length CHECK so the corrupt-row plant succeeds; + // the subject of this test is the Rust-side defense in + // `UsernameStore::load_from_pg`, not the DB-level CHECK. + sqlx::query("ALTER TABLE usernames DROP CONSTRAINT usernames_address_length") + .execute(&pool) + .await + .expect("drop usernames_address_length"); sqlx::query("INSERT INTO usernames (name, address) VALUES ($1, $2)") .bind("alice") .bind(vec![0u8; 7]) From ce4307c41a740a2f1ee480d6e9957b140c44003f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 23:09:02 +0200 Subject: [PATCH 10/19] docs(migrations): replace remaining "server" with "node" in SQL comments --- node/migrations/0001_initial.sql | 4 ++-- node/migrations/0003_pending_inscriptions.sql | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/node/migrations/0001_initial.sql b/node/migrations/0001_initial.sql index 51be7007..d20ef81d 100644 --- a/node/migrations/0001_initial.sql +++ b/node/migrations/0001_initial.sql @@ -1,9 +1,9 @@ --- Initial Postgres schema for the zkCoins server state-layer. +-- Initial Postgres schema for the zkCoins node state-layer. -- -- This migration is part of PR-A1 in the 3-PR Postgres migration -- series (file-based bincode -> Postgres). The schema is installed -- by `db::connect_and_migrate`; nothing here is wired into the --- server bootstrap yet — that happens in PR-A2 (state + latest block) +-- node bootstrap yet — that happens in PR-A2 (state + latest block) -- and PR-A3 (accounts + usernames). -- -- Design notes: diff --git a/node/migrations/0003_pending_inscriptions.sql b/node/migrations/0003_pending_inscriptions.sql index 216e8c5c..16c46808 100644 --- a/node/migrations/0003_pending_inscriptions.sql +++ b/node/migrations/0003_pending_inscriptions.sql @@ -41,7 +41,7 @@ -- instead of a silent state-machine drift. -- * The partial index on `status <> 'complete'` keeps the resumer's -- boot-time scan O(pending) instead of O(total). After enough --- mints this list will be perpetually empty on a healthy server. +-- mints this list will be perpetually empty on a healthy node. CREATE TABLE pending_inscriptions ( id BIGSERIAL PRIMARY KEY, From d1d691d799365cbb1b84a5556f5776166d8e8da0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 23:16:37 +0200 Subject: [PATCH 11/19] test(account_node): also disable accounts_history_trigger in wrong-length test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DB-level CHECK drop alone is not enough — the `accounts_history_trigger` fires AFTER INSERT on `accounts` and attempts to write the 7-byte address to `account_history`, where the matching `account_history_address_length` CHECK now blocks it. Disable the trigger before planting the corrupt row. The history path is not the subject of this test; we are exercising `LoadAccountNodeError::BadAddressLength` only. (Other 4 tests in the previous commit don't hit this cascade — there is no AFTER INSERT trigger on `usernames`, `latest_block`, or `mmr_root_index`.) --- node/src/account_node_tests.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/node/src/account_node_tests.rs b/node/src/account_node_tests.rs index bac85ab0..7c3a597b 100644 --- a/node/src/account_node_tests.rs +++ b/node/src/account_node_tests.rs @@ -564,9 +564,15 @@ async fn test_load_from_pg_rejects_wrong_address_length() { // the actual subject of this test: the Rust-side // `LoadAccountNodeError::BadAddressLength` defense in // `load_from_pg`. Drop the constraint inside this per-test - // container so the corrupt-row plant succeeds; the constraint - // is itself covered indirectly by the migration test that runs - // `connect_and_migrate` here. + // container so the corrupt-row plant succeeds. The 0008 + // `accounts_history_trigger` would also fail on the matching + // `account_history_address_length` CHECK if it fired against + // the 7-byte address, so disable the trigger for this test — + // we are not exercising the history path here. + sqlx::query("ALTER TABLE accounts DISABLE TRIGGER accounts_history_trigger") + .execute(&pool) + .await + .expect("disable accounts_history_trigger"); sqlx::query("ALTER TABLE accounts DROP CONSTRAINT accounts_address_length") .execute(&pool) .await From 93117c4fa0678dda21688e16d2c4ad94d1e7eadf Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 23:32:24 +0200 Subject: [PATCH 12/19] test(db): update creates_all_tables to expect the full 0010 schema The existing assertion listed only the pre-#113 set of 8 tables. After migrations 0006-0010 the schema is 19 tables + the `accounts_history_trigger`; the introspection-query assertion now reflects that. --- node/src/db_tests.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs index b1bf8e9c..fdf81164 100644 --- a/node/src/db_tests.rs +++ b/node/src/db_tests.rs @@ -56,21 +56,38 @@ async fn connect_and_migrate_creates_all_tables() { .await .expect("introspection query failed"); let names: Vec = rows.into_iter().map(|r| r.get::(0)).collect(); - // _sqlx_migrations is created implicitly by sqlx::migrate!. - // `pending_inscriptions` lands via 0003_pending_inscriptions.sql - // (Phase B). `mmr_root_index` lands via 0004_mmr_root_index.sql - // (Phase C). `minting_meta` is created by 0002 then dropped by - // 0005 (Phase D), so it is absent from the final schema. + // Full expected schema after all migrations 0001-0010 (alphabetic + // by `ORDER BY table_name`). `_sqlx_migrations` is created + // implicitly by `sqlx::migrate!`. `minting_meta` (0002) is + // dropped by 0005 (Phase D), absent from the final schema. + // + // Counts: + // * Pre-#113 schema (0001-0005): 8 tables + // * After 0006 (kind): 8 tables (ALTER only) + // * After 0007 (request_log): 9 tables + // * After 0008 (full DB trail): 19 tables + 1 trigger + // * After 0009 / 0010: 19 tables (polish only) assert_eq!( names, vec![ "_sqlx_migrations".to_string(), + "account_history".to_string(), "accounts".to_string(), + "block_log".to_string(), + "boot_log".to_string(), + "coin_proof_store".to_string(), + "error_log".to_string(), + "esplora_log".to_string(), "latest_block".to_string(), "mmr_root_index".to_string(), "mmr_state".to_string(), + "observed_inscriptions".to_string(), "pending_inscriptions".to_string(), + "request_log".to_string(), "smt_state".to_string(), + "state_update_log".to_string(), + "tx_mining_log".to_string(), + "username_claim_log".to_string(), "usernames".to_string(), ] ); From bf05ceef6b8d410501403d23b4b64e53ac81e809 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 26 May 2026 23:50:33 +0200 Subject: [PATCH 13/19] test(db): DROP TABLE pending_inscriptions CASCADE in rollback test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0010 added FK constraints from `tx_mining_log.commit_txid` and `coin_proof_store.consumed_by_commit_txid` to `pending_inscriptions(commit_txid)`. The existing `persist_state_and_mark_complete_tx_rollback_on_failure_leaves_state_untouched` test synthesizes a mid-tx failure by dropping `pending_inscriptions` — now blocked by the new FK dependencies unless CASCADE is passed. Switch the DROP to CASCADE. The dependent tables and their FK constraints are torn down too, which is fine for the test (it owns the throw-away container) and exercises the exact same rollback invariant. --- node/src/db_tests.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs index 0129e874..18d67238 100644 --- a/node/src/db_tests.rs +++ b/node/src/db_tests.rs @@ -667,7 +667,17 @@ async fn persist_state_and_mark_complete_tx_rollback_on_failure_leaves_state_unt // UPDATE inside the helper will fail with "relation does not // exist", the transaction rolls back, and the smt/mmr UPSERTs // performed earlier in the same tx are undone. - sqlx::query("DROP TABLE pending_inscriptions") + // + // CASCADE is required after migration 0010 added FK constraints + // from `tx_mining_log.commit_txid` and + // `coin_proof_store.consumed_by_commit_txid` to + // `pending_inscriptions(commit_txid)`. Without CASCADE the DROP + // is rejected by Postgres with "cannot drop table … because + // other objects depend on it". The dependent tables and their FK + // constraints get dropped along with the parent — fine for this + // test, which is exercising a synthetic mid-tx failure, not a + // real schema change. + sqlx::query("DROP TABLE pending_inscriptions CASCADE") .execute(&pool) .await .unwrap(); From b852b533a2413d32fb8829d236575e4c1c6eb5d5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 27 May 2026 00:10:23 +0200 Subject: [PATCH 14/19] fix(publisher): revert blanket status='failed' on broadcast error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #119 refactor `update_pending_failure_reason → mark_pending_failed` also promoted `status` to `'failed'` on every broadcast error. That erases the state-machine distinction `resume_pending_inscriptions` needs: a row in `commit_broadcast` (commit landed, reveal failed) must stay in `commit_broadcast` so resume re-broadcasts only the reveal. Forcing `'failed'` on partial-success states made resume re-attempt the commit — chain saves us with `txn-already-known`, but the row has lost its truth. Revert to the pre-#119 shape: only `failure_reason` is mutated, `status` stays under the state machine's control. `status = 'failed'` is reserved for truly-terminal callers (retry exhaustion, operator-initiated abort) — none yet, but the CHECK enum keeps the slot. Surface symptom: `publisher::tests::broadcast_advances_to_commit_broadcast_after_commit_success` asserted `status = 'commit_broadcast'` post-failure, was getting `'failed'`. With this revert it passes again, and resume retains its full state-machine vocabulary. The `pending_inscriptions_failed_reason_required` CHECK from #120 still holds (one-way implication: `status='failed' ⇒ failure_reason IS NOT NULL`). --- node/src/db.rs | 29 ++++++++++++++++++----------- node/src/publisher.rs | 23 ++++++++++++++++------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/node/src/db.rs b/node/src/db.rs index bddb7d71..9e777e86 100644 --- a/node/src/db.rs +++ b/node/src/db.rs @@ -446,24 +446,31 @@ pub async fn insert_boot_log(pool: &PgPool, entry: &BootLogEntry) -> Result<(), Ok(()) } -/// Mark a `pending_inscriptions` row as definitively failed: status = -/// `'failed'` and `failure_reason` set, both in one UPDATE. Pairs the -/// status discriminator with the error-chain text so the resume path -/// can skip permanently-failed rows AND the operator can answer -/// "why?" from SQL alone. Called from the publisher's error paths. +/// Record the most recent broadcast error against a +/// `pending_inscriptions` row WITHOUT changing its `status`. /// -/// Note: the existing `resume_pending_inscriptions` still loads -/// `status <> 'complete'` and re-drives `failed` rows. If a stricter -/// policy is wanted later (skip `failed` outright), that's a one-line -/// SQL change in `load_pending_in_progress`. -pub async fn mark_pending_failed( +/// The status discriminator carries state-machine semantics that the +/// resume path depends on: a `commit_broadcast` row means "commit +/// landed on chain, only the reveal needs to be re-driven" while +/// `constructed` means "neither leg landed yet, broadcast both". A +/// blanket promotion to `status = 'failed'` on every error would +/// erase that distinction and force resume to re-broadcast a commit +/// that already landed (the chain rejects it with +/// `txn-already-known` so the recovery is graceful, but the state +/// machine has lost its truth). +/// +/// `failure_reason` is therefore the only column this helper mutates. +/// `status = 'failed'` stays reserved for truly-terminal callers +/// (retry exhaustion, operator-initiated abort) — none of which exist +/// yet, but the CHECK enum keeps the spot ready. +pub async fn update_pending_failure_reason( pool: &PgPool, commit_txid: &[u8], failure_reason: &str, ) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE pending_inscriptions \ - SET status = 'failed', failure_reason = $1, updated_at = NOW() \ + SET failure_reason = $1, updated_at = NOW() \ WHERE commit_txid = $2", ) .bind(failure_reason) diff --git a/node/src/publisher.rs b/node/src/publisher.rs index d8efa055..78cc57e5 100644 --- a/node/src/publisher.rs +++ b/node/src/publisher.rs @@ -724,18 +724,27 @@ pub async fn create_and_broadcast_inscription( } Err(e) => { println!("Failed to broadcast transactions: {}", e); - // Mark the row as definitively failed: status = 'failed' AND - // failure_reason set in one UPDATE. The status discriminator - // matches the error chain text so the operator can answer - // "why did this Send not land?" from SQL alone, and the - // CHECK-allowed `failed` value is no longer dead code. + // Record the error chain on the row without changing the + // status discriminator: the broadcast may have advanced + // the state machine to `commit_broadcast` (commit landed + // on chain but reveal failed) and the resume path needs + // to keep that distinction so it re-broadcasts only the + // reveal. A blanket `status = 'failed'` would erase the + // distinction and force resume to re-attempt the commit + // (chain returns `txn-already-known` and recovers, but + // the row would have lost its truth in the meantime). + // + // `status = 'failed'` is reserved for truly-terminal + // callers (retry exhaustion, operator abort) — none yet, + // but the CHECK enum keeps the slot ready. if let Some(pool) = pool { let reason = format!("{}", e); if let Err(persist_err) = - db::mark_pending_failed(pool, commit_txid.as_byte_array(), &reason).await + db::update_pending_failure_reason(pool, commit_txid.as_byte_array(), &reason) + .await { eprintln!( - "Failed to mark pending_inscriptions row as failed for {}: {}", + "Failed to persist failure_reason for {}: {}", commit_txid, persist_err ); } From af52a7f129ddb1df893aaa4fd80224cc1f50366a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 27 May 2026 00:17:24 +0200 Subject: [PATCH 15/19] fix(db): tag account_history.source correctly for mint / send / receive paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `accounts_history_trigger` (migration 0008) reads `current_setting('zkcoins.account_source', TRUE)` and defaults to `'scanner'` when the GUC is unset. Without explicit tagging, every HTTP-handler-driven account mutation was being recorded as `source='scanner'` — erasing the semantic distinction the column was added to capture. Add `db::upsert_account_with_source(pool, address, data, source)`: opens a `BEGIN/COMMIT` envelope, runs `SELECT set_config('zkcoins.account_source', $1, true)` (the safe parameterized equivalent of `SET LOCAL`), then upserts. The GUC's local scope means the tag only applies to this transaction, never bleeding into adjacent / concurrent ones. `commit_mint_tx` already had a transaction — extend it to set the GUC to `'mint'` at the top so the bundled per-recipient upserts all get tagged consistently. Call-site changes: | Site | Source | |---------------------------------------------------------|------------| | `db::commit_mint_tx` (all rows) | `'mint'` | | `router::receive_coin_handler` (line ~607) | `'receive'`| | `runtime::broadcast_commit_and_deliver` (line ~286) | `'receive'`| | — recipient row updated post-`receive_coin` | | | `router::send_coin_handler` (line ~755) | `'send'` | `db::upsert_account` (default `'scanner'`) stays in place for the remaining callers: `account_node::persist_account`, which is driven from the scanner callback in `main.rs`. `account_history.source` queries now return the expected enum value for every operator forensic question (`WHERE source = 'mint'` etc.). Verified locally ---------------- * `cargo fmt --all --check` ✓ * `cargo clippy -p node -p shared -- -D warnings` ✓ * `cargo check -p node --tests` ✓ --- node/src/db.rs | 46 +++++++++++++++++++++++++++++++++++++++++++++ node/src/router.rs | 13 ++++++++++--- node/src/runtime.rs | 4 +++- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/node/src/db.rs b/node/src/db.rs index 9e777e86..aa6a0730 100644 --- a/node/src/db.rs +++ b/node/src/db.rs @@ -739,6 +739,45 @@ pub async fn load_all_accounts(pool: &PgPool) -> Result, Vec)>, /// Upsert a single account row. The bincode blob in `data` is /// considered authoritative — concurrent writers must serialize at /// the application layer (`Arc>` in main.rs). +/// Upsert an account row and tag the matching `account_history` entry +/// with `source` (one of `'mint','send','receive','scanner','recovery'`). +/// +/// The trigger added by migration 0008 reads `current_setting('zkcoins +/// .account_source', TRUE)` so the caller can override the default +/// `'scanner'`. `set_config(..., is_local := true)` is the safe, +/// parameterized equivalent of `SET LOCAL` — the value goes through +/// sqlx's bind path so no string interpolation is involved, and the +/// setting only lives for the duration of this transaction. The +/// surrounding `BEGIN/COMMIT` is required because `is_local := true` +/// is a no-op outside a transaction. +pub async fn upsert_account_with_source( + pool: &PgPool, + address: &[u8], + data: &[u8], + source: &str, +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query("SELECT set_config('zkcoins.account_source', $1, true)") + .bind(source) + .execute(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO accounts (address, data, updated_at) \ + VALUES ($1, $2, NOW()) \ + ON CONFLICT (address) DO UPDATE \ + SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at", + ) + .bind(address) + .bind(data) + .execute(&mut *tx) + .await?; + tx.commit().await +} + +/// Upsert an account with `account_history.source = 'scanner'` — +/// the default for callers without semantic context (state replay, +/// recovery CLI, persist_account from the scanner callback). +/// Semantically-aware callers should use `upsert_account_with_source`. pub async fn upsert_account(pool: &PgPool, address: &[u8], data: &[u8]) -> Result<(), sqlx::Error> { sqlx::query( "INSERT INTO accounts (address, data, updated_at) \ @@ -822,6 +861,13 @@ pub async fn resolve_username(pool: &PgPool, name: &str) -> Result Result<(), sqlx::Error> { let mut tx = pool.begin().await?; + // Tag every `account_history` row written by the trigger as + // `source = 'mint'`. `set_config(..., is_local := true)` only + // takes effect for the lifetime of THIS transaction, so the + // tag does not bleed into adjacent / concurrent transactions. + sqlx::query("SELECT set_config('zkcoins.account_source', 'mint', true)") + .execute(&mut *tx) + .await?; for (address, data) in accounts { sqlx::query( "INSERT INTO accounts (address, data, updated_at) \ diff --git a/node/src/router.rs b/node/src/router.rs index 0220fede..1b361dca 100644 --- a/node/src/router.rs +++ b/node/src/router.rs @@ -604,7 +604,9 @@ async fn receive_coin_handler( match snapshot { Some(bytes) => { let addr_bytes = digest_to_bytes(&recipient); - if let Err(e) = db::upsert_account(&state.pool, &addr_bytes, &bytes).await { + if let Err(e) = + db::upsert_account_with_source(&state.pool, &addr_bytes, &bytes, "receive").await + { eprintln!("Failed to upsert recipient account after receive: {}", e); } Json(SendCoinResponse { @@ -751,8 +753,13 @@ async fn send_coin_handler( // We log and continue rather than failing the request, // which mirrors the pre-Postgres `save_to_file` semantics. let addr_bytes = digest_to_bytes(&from_address); - if let Err(e) = - db::upsert_account(&state.pool, &addr_bytes, &updated_account_bytes).await + if let Err(e) = db::upsert_account_with_source( + &state.pool, + &addr_bytes, + &updated_account_bytes, + "send", + ) + .await { eprintln!("Failed to upsert sender account after send: {}", e); } diff --git a/node/src/runtime.rs b/node/src/runtime.rs index b3500f53..9b3c3d43 100644 --- a/node/src/runtime.rs +++ b/node/src/runtime.rs @@ -283,7 +283,9 @@ pub(crate) async fn broadcast_commit_and_deliver( }; if let Some(bytes) = snapshot { let addr_bytes = zkcoins_program::hash::digest_to_bytes(&recipient); - if let Err(e) = db::upsert_account(&state.pool, &addr_bytes, &bytes).await { + if let Err(e) = + db::upsert_account_with_source(&state.pool, &addr_bytes, &bytes, "receive").await + { eprintln!("Failed to upsert account after commit: {}", e); } } From 79f333518e580318543ea9a37e9b2ed3396c81d8 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 27 May 2026 01:41:32 +0200 Subject: [PATCH 16/19] test: close 100% line/function coverage gate for #113 stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI coverage gate landed at 92.89%L / 88.93%F because audit.rs had zero tests and db.rs grew ~14 new insert/update helpers without matching coverage. Adds: * `node/src/audit_tests.rs` (new file, wired via `mod tests`): - `headers_to_json` non-UTF-8 binary-hex branch - `headers_to_json` repeated-header collapse branch - `buffer_body` collect-error fallback to empty bytes - `audit_middleware_persists_request_response_pair` end-to-end against a real testcontainers pool — verifies all columns incl. client_ip (CF-Connecting-IP path) - `audit_middleware_falls_back_to_x_forwarded_for` — fallback path when CF-Connecting-IP is absent * `db_tests.rs` — happy-path INSERTs for every new helper: insert_request_log, insert_esplora_log, insert_error_log, insert_block_log (idempotent ON CONFLICT), insert_observed_inscription + mark_observed_inscription_integrated (full lifecycle), insert_state_update_log, insert_account_history, insert_username_claim_log, insert_tx_mining_log (covers FK to pending_inscriptions), insert_boot_log, update_pending_failure_reason (verifies status unchanged), upsert_account_with_source (verifies the trigger writes account_history with the GUC-supplied source), get_inscription_summary_by_commit_txid (Some + None + full-row format incl. txid-display-order reversal). Plus the `InscriptionKind::from_db_str` `_ => None` branch. * `router_tests.rs` — new module covering `GET /api/inscriptions/:txid`: bad-hex 422, wrong-length 422, unknown-txid 404, known-txid 200 with summary JSON, DB-error 500 (DROP TABLE … CASCADE to force the SELECT to fail). Plus a `claim_username_precheck_reject_persists_log_row` test that waits for the fire-and-forget `tokio::spawn` username_claim_log insert to land, closing the spawn-body coverage gap at router.rs:1766. Verified locally ---------------- * `cargo fmt --all --check` ✓ * `cargo clippy -p node -p shared -- -D warnings` ✓ * `cargo check -p node --tests` ✓ --- node/src/audit.rs | 4 + node/src/audit_tests.rs | 270 +++++++++++++++++++++++++++ node/src/db_tests.rs | 383 +++++++++++++++++++++++++++++++++++++++ node/src/router_tests.rs | 207 +++++++++++++++++++++ 4 files changed, 864 insertions(+) create mode 100644 node/src/audit_tests.rs diff --git a/node/src/audit.rs b/node/src/audit.rs index 26c92874..6abc74e6 100644 --- a/node/src/audit.rs +++ b/node/src/audit.rs @@ -168,3 +168,7 @@ pub(crate) async fn audit_log_middleware( Response::from_parts(resp_parts, Body::from(resp_bytes)) } + +#[cfg(test)] +#[path = "audit_tests.rs"] +mod tests; diff --git a/node/src/audit_tests.rs b/node/src/audit_tests.rs new file mode 100644 index 00000000..b7c1c328 --- /dev/null +++ b/node/src/audit_tests.rs @@ -0,0 +1,270 @@ +//! Unit tests for the audit-log middleware. +//! +//! The middleware is wired in `router::create_router` as the outermost +//! layer. We don't spin up the full router here — we exercise the two +//! pure helpers directly (`headers_to_json`, `buffer_body`) and then +//! drive the middleware through a minimal axum app so the +//! happy-path / binary-header / body-error branches all reach +//! `db::insert_request_log` and the right JSONB shape lands in +//! `request_log`. + +use super::*; +use axum::body::Body; +use axum::http::{HeaderName, HeaderValue, Method, Request, StatusCode}; +use axum::middleware::from_fn_with_state; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::Router; +use tower::ServiceExt; + +use crate::db::connect_and_migrate; +use crate::publisher::EsploraConfig; +use crate::router::{AppState, ProofStore}; +use bitcoin::bip32::Xpriv; +use bitcoin::Network; +use std::sync::{Arc, Mutex}; + +/// Cover the binary-header branch of `headers_to_json`: a header value +/// that is not valid UTF-8 must land as `{"_binary": ""}` so the +/// JSONB row stays round-trippable. The happy-path UTF-8 branch is +/// covered by every other test. +#[test] +fn headers_to_json_renders_non_utf8_value_as_binary_hex() { + let mut headers = axum::http::HeaderMap::new(); + // 0xFF is not valid UTF-8. + headers.insert( + HeaderName::from_static("x-binary"), + HeaderValue::from_bytes(&[0xFFu8, 0xFE]).unwrap(), + ); + let value = headers_to_json(&headers); + let obj = value.as_object().expect("headers_to_json returns Object"); + let binary_obj = obj + .get("x-binary") + .and_then(|v| v.as_object()) + .expect("non-utf8 header rendered as nested object"); + assert_eq!( + binary_obj.get("_binary").and_then(|v| v.as_str()), + Some("fffe") + ); +} + +/// Repeated headers (e.g. `Set-Cookie`) collapse into a JSON array. +/// First-occurrence stays as a `String`, subsequent matches promote +/// to `[String, String, …]`. +#[test] +fn headers_to_json_collapses_repeated_keys_into_array() { + let mut headers = axum::http::HeaderMap::new(); + headers.append( + HeaderName::from_static("set-cookie"), + HeaderValue::from_static("a=1"), + ); + headers.append( + HeaderName::from_static("set-cookie"), + HeaderValue::from_static("b=2"), + ); + let value = headers_to_json(&headers); + let arr = value + .as_object() + .and_then(|o| o.get("set-cookie")) + .and_then(|v| v.as_array()) + .expect("repeated key rendered as array"); + let values: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect(); + assert_eq!(values, vec!["a=1", "b=2"]); +} + +/// `buffer_body` MUST never panic — it returns an empty `Bytes` on +/// any underlying error. We synthesize a body that fails to collect +/// to exercise the `Err(_) => eprintln + empty` arm. +#[tokio::test] +async fn buffer_body_returns_empty_on_collect_error() { + // `Body::from_stream` over a stream that yields an error frame + // is the cheapest way to drive `BodyExt::collect` into `Err(_)`. + let stream = futures_util::stream::once(async { + Err::<&[u8], std::io::Error>(std::io::ErrorKind::Other.into()) + }); + let body = Body::from_stream(stream); + let buffered = buffer_body(body).await; + assert_eq!(buffered.len(), 0); +} + +/// Build an `AppState` that points at a fresh testcontainers Postgres +/// pool. Everything else (account_node, proof_store, minting_account, +/// username_store, esplora_config) is filled with a smallest-possible +/// dummy because the audit middleware never reads them. +async fn build_state_with_pool() -> ( + AppState, + testcontainers::ContainerAsync, +) { + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("postgres container"); + let host = container.get_host().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = connect_and_migrate(&url) + .await + .expect("connect_and_migrate"); + + // Minting account: any deterministic Xpriv works; the audit + // middleware never reads it. + let xpriv = Xpriv::new_master(Network::Signet, &[0xAB; 32]).expect("xpriv"); + let minting_account = shared::ClientAccount::new(xpriv); + + let state_arc = Arc::new(Mutex::new(crate::state::State::new())); + let account_node = crate::account_node::AccountNode::new(state_arc); + let esplora_config = EsploraConfig { + url: "http://127.0.0.1:1".to_string(), + is_mainnet: false, + network_name: "Mutinynet".to_string(), + ws_url: None, + track_tx_timeout: None, + }; + + let tmp = tempfile::tempdir().expect("tempdir"); + let proof_dir = tmp.path().to_str().unwrap().to_string(); + + let state = AppState { + account_node: Arc::new(Mutex::new(account_node)), + proof_store: Arc::new(ProofStore::new(&proof_dir)), + minting_account: Arc::new(Mutex::new(minting_account)), + username_store: Arc::new(Mutex::new(crate::username::UsernameStore::new())), + pool: Arc::new(pool), + esplora_config: Arc::new(esplora_config), + phase2_reached: Arc::new(tokio::sync::Notify::new()), + phase3_release_lock: Arc::new(tokio::sync::Mutex::new(())), + state_advance_release_lock: Arc::new(tokio::sync::Mutex::new(())), + }; + // tempdir lives until the test ends (Drop on test exit). + std::mem::forget(tmp); + (state, container) +} + +/// Drive the middleware end-to-end: a small handler that echoes the +/// request body, an audit layer that should write a row containing +/// the bodies, headers, status, and duration. The fire-and-forget +/// spawn means we sleep briefly after the response to let the +/// insert land. +#[tokio::test] +async fn audit_middleware_persists_request_response_pair() { + let (state, _container) = build_state_with_pool().await; + let pool = state.pool.clone(); + + async fn echo_handler(body: Body) -> impl IntoResponse { + let bytes = http_body_util::BodyExt::collect(body) + .await + .unwrap() + .to_bytes(); + (StatusCode::OK, bytes) + } + + let app = Router::new() + .route("/echo", post(echo_handler)) + .with_state(state.clone()) + .layer(from_fn_with_state(state.clone(), audit_log_middleware)); + + let req = Request::builder() + .method(Method::POST) + .uri("/echo?trace=yes") + .header("user-agent", "audit-test/1.0") + .header("cf-connecting-ip", "203.0.113.42") + .body(Body::from("hello")) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // Fire-and-forget tokio::spawn — wait briefly for the insert. + for _ in 0..40 { + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM request_log") + .fetch_one(pool.as_ref()) + .await + .unwrap(); + if count >= 1 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + let ( + method, + path, + query, + client_ip, + user_agent, + response_status, + duration_us, + request_body, + response_body, + ): ( + String, + String, + Option, + Option, + Option, + i16, + i64, + Vec, + Vec, + ) = sqlx::query_as( + "SELECT method, path, query, client_ip, user_agent, response_status, duration_us, request_body, response_body \ + FROM request_log", + ) + .fetch_one(pool.as_ref()) + .await + .expect("audit insert must land"); + assert_eq!(method, "POST"); + assert_eq!(path, "/echo"); + assert_eq!(query.as_deref(), Some("trace=yes")); + // CF-Connecting-IP wins over remote_addr / X-Forwarded-For. + assert_eq!(client_ip.as_deref(), Some("203.0.113.42")); + assert_eq!(user_agent.as_deref(), Some("audit-test/1.0")); + assert_eq!(response_status, 200); + assert!(duration_us >= 0); + assert_eq!(request_body, b"hello"); + assert_eq!(response_body, b"hello"); +} + +/// `X-Forwarded-For` is the fallback when `CF-Connecting-IP` is +/// absent. Multi-value `XFF` collapses to its first segment. +#[tokio::test] +async fn audit_middleware_falls_back_to_x_forwarded_for() { + let (state, _container) = build_state_with_pool().await; + let pool = state.pool.clone(); + + async fn ok_handler() -> impl IntoResponse { + StatusCode::NO_CONTENT + } + + let app = Router::new() + .route("/ping", post(ok_handler)) + .with_state(state.clone()) + .layer(from_fn_with_state(state, audit_log_middleware)); + + let req = Request::builder() + .method(Method::POST) + .uri("/ping") + .header("x-forwarded-for", "198.51.100.7, 10.0.0.1") + .body(Body::empty()) + .unwrap(); + let _ = app.oneshot(req).await.unwrap(); + + for _ in 0..40 { + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM request_log") + .fetch_one(pool.as_ref()) + .await + .unwrap(); + if count >= 1 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + let (client_ip,): (Option,) = sqlx::query_as("SELECT client_ip FROM request_log") + .fetch_one(pool.as_ref()) + .await + .unwrap(); + assert_eq!(client_ip.as_deref(), Some("198.51.100.7")); +} diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs index 18d67238..faf3d60a 100644 --- a/node/src/db_tests.rs +++ b/node/src/db_tests.rs @@ -774,3 +774,386 @@ async fn persist_state_and_mark_complete_tx_idempotent_on_already_complete_row() "guarded UPDATE must NOT bump updated_at on already-complete row" ); } + +// ============================================================================ +// Coverage tests for migration 0006-0010 helpers (added in this PR stack). +// Each test exercises one insert path against a fresh test container so the +// 100% line/function gate stays green. +// ============================================================================ + +#[test] +fn inscription_kind_from_db_str_returns_none_for_invalid() { + // The `_ => None` arm in `from_db_str` is reached only by bogus + // input — every DB row goes through the CHECK constraint + // ('mint' | 'send'). Tested directly. + assert!(InscriptionKind::from_db_str("nope").is_none()); + assert!(InscriptionKind::from_db_str("").is_none()); +} + +#[tokio::test] +async fn insert_request_log_writes_row() { + let (pool, _container) = setup_pool().await; + let entry = RequestLogEntry { + method: "POST".into(), + path: "/api/mint".into(), + query: Some("debug=1".into()), + remote_addr: Some("127.0.0.1:54321".into()), + client_ip: Some("203.0.113.7".into()), + user_agent: Some("wallet/1.0".into()), + request_headers: serde_json::json!({"content-type": "application/json"}), + request_body: b"{}".to_vec(), + response_status: 200, + response_headers: serde_json::json!({"x-trace-id": "abc"}), + response_body: b"{\"ok\":true}".to_vec(), + duration_us: 1234, + }; + insert_request_log(&pool, &entry).await.unwrap(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM request_log") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn insert_esplora_log_writes_row() { + let (pool, _container) = setup_pool().await; + let entry = EsploraLogEntry { + direction: "outbound_http", + method: Some("POST".into()), + url: "http://example/tx".into(), + request_body: Some(b"raw".to_vec()), + response_status: Some(200), + response_body: Some(b"ok".to_vec()), + duration_us: Some(42), + trigger_source: Some("mint".into()), + triggering_request_log_id: None, + }; + insert_esplora_log(&pool, &entry).await.unwrap(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM esplora_log") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn insert_error_log_writes_row() { + let (pool, _container) = setup_pool().await; + let entry = ErrorLogEntry { + severity: "error", + source: "publisher::broadcast".into(), + message: "broadcast failed".into(), + error_chain: Some("io: connection refused".into()), + request_log_id: None, + }; + insert_error_log(&pool, &entry).await.unwrap(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM error_log") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn insert_block_log_writes_row_and_is_idempotent() { + let (pool, _container) = setup_pool().await; + let entry = BlockLogEntry { + block_hash: vec![0x11; 32], + block_height: Some(7), + inscription_count: 2, + processing_duration_us: Some(99), + }; + insert_block_log(&pool, &entry).await.unwrap(); + // ON CONFLICT (block_hash) DO NOTHING — second insert is a no-op. + insert_block_log(&pool, &entry).await.unwrap(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM block_log") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn insert_observed_inscription_and_mark_integrated() { + let (pool, _container) = setup_pool().await; + let commit_txid = vec![0x22; 32]; + let entry = ObservedInscriptionEntry { + commit_txid: commit_txid.clone(), + block_hash: Some(vec![0x33; 32]), + block_height: Some(42), + source: "external", + commitment: vec![0xAA; 145], + public_key: vec![0x03; 33], + integrated: false, + }; + insert_observed_inscription(&pool, &entry).await.unwrap(); + // Idempotent ON CONFLICT — second insert is a no-op. + insert_observed_inscription(&pool, &entry).await.unwrap(); + + // Pre-flip: integrated=false, integrated_at IS NULL. + let (pre_integrated,): (bool,) = + sqlx::query_as("SELECT integrated FROM observed_inscriptions WHERE commit_txid = $1") + .bind(&commit_txid[..]) + .fetch_one(&pool) + .await + .unwrap(); + assert!(!pre_integrated); + + mark_observed_inscription_integrated(&pool, &commit_txid) + .await + .unwrap(); + + // Post-flip: both columns advanced; the logical-pair CHECK from 0010 + // would have rejected a half-update. + let (post_integrated, has_ts): (bool, bool) = sqlx::query_as( + "SELECT integrated, integrated_at IS NOT NULL FROM observed_inscriptions WHERE commit_txid = $1", + ) + .bind(&commit_txid[..]) + .fetch_one(&pool) + .await + .unwrap(); + assert!(post_integrated); + assert!(has_ts); + + // Second flip is a no-op (WHERE integrated = FALSE filter). + mark_observed_inscription_integrated(&pool, &commit_txid) + .await + .unwrap(); +} + +#[tokio::test] +async fn insert_state_update_log_writes_row() { + let (pool, _container) = setup_pool().await; + let entry = StateUpdateLogEntry { + trigger_source: "mint", + commit_txid: Some(vec![0x44; 32]), + prev_mmr_root: vec![0x55; 32], + new_mmr_root: vec![0x66; 32], + smt_root_before: vec![0x77; 32], + smt_root_after: vec![0x88; 32], + commitment_count: 1, + }; + insert_state_update_log(&pool, &entry).await.unwrap(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM state_update_log") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn insert_account_history_writes_row_directly() { + let (pool, _container) = setup_pool().await; + let entry = AccountHistoryEntry { + address: vec![0x99; 32], + prev_data: None, + new_data: b"new-blob".to_vec(), + source: "recovery", + triggering_commit_txid: None, + triggering_request_log_id: None, + }; + insert_account_history(&pool, &entry).await.unwrap(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM account_history") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn insert_username_claim_log_writes_row() { + let (pool, _container) = setup_pool().await; + let entry = UsernameClaimLogEntry { + requested_username: "Alice".into(), + normalized_username: "alice".into(), + address: vec![0xAA; 32], + signature: vec![0xBB; 64], + success: true, + reject_reason: None, + request_log_id: None, + }; + insert_username_claim_log(&pool, &entry).await.unwrap(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM username_claim_log") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn insert_tx_mining_log_writes_row() { + let (pool, _container) = setup_pool().await; + // The 0010 FK from `tx_mining_log.commit_txid` to + // `pending_inscriptions(commit_txid)` requires the parent row first. + let commit_txid = [0xCC; 32]; + let reveal_txid = [0xDD; 32]; + insert_pending_inscription( + &pool, + &commit_txid, + &reveal_txid, + InscriptionKind::Mint, + b"commitment", + b"commit-tx", + b"reveal-tx", + 1000, + ) + .await + .unwrap(); + + let entry = TxMiningLogEntry { + target_prefix: "4242".into(), + nonces_tried: 100, + duration_us: 1234, + final_nonce: Some(99), + final_txid: vec![0xEE; 32], + commit_txid: Some(commit_txid.to_vec()), + }; + insert_tx_mining_log(&pool, &entry).await.unwrap(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM tx_mining_log") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn insert_boot_log_writes_row() { + let (pool, _container) = setup_pool().await; + let entry = BootLogEntry { + event_type: "startup".into(), + message: "node started".into(), + metadata: Some(serde_json::json!({"pid": 42})), + }; + insert_boot_log(&pool, &entry).await.unwrap(); + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM boot_log") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, 1); +} + +#[tokio::test] +async fn update_pending_failure_reason_records_error_without_changing_status() { + let (pool, _container) = setup_pool().await; + let commit_txid = [0x77; 32]; + let reveal_txid = [0x78; 32]; + insert_pending_inscription( + &pool, + &commit_txid, + &reveal_txid, + InscriptionKind::Send, + b"c", + b"ctx", + b"rtx", + 500, + ) + .await + .unwrap(); + update_pending_status(&pool, &commit_txid, PENDING_STATUS_COMMIT_BROADCAST) + .await + .unwrap(); + + update_pending_failure_reason(&pool, &commit_txid, "boom") + .await + .unwrap(); + + let (status, reason): (String, Option) = sqlx::query_as( + "SELECT status, failure_reason FROM pending_inscriptions WHERE commit_txid = $1", + ) + .bind(&commit_txid[..]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(status, PENDING_STATUS_COMMIT_BROADCAST); + assert_eq!(reason.as_deref(), Some("boom")); +} + +#[tokio::test] +async fn upsert_account_with_source_tags_history_via_trigger() { + let (pool, _container) = setup_pool().await; + let address = vec![0x10; 32]; + upsert_account_with_source(&pool, &address, b"v1", "mint") + .await + .unwrap(); + let (src, prev_data): (String, Option>) = + sqlx::query_as("SELECT source, prev_data FROM account_history WHERE address = $1") + .bind(&address[..]) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(src, "mint"); + assert!(prev_data.is_none(), "first insert has no prev_data"); + + // Second upsert: trigger sees TG_OP='UPDATE' and OLD.data != NEW.data, + // writes another row with prev_data=Some(b"v1"). + upsert_account_with_source(&pool, &address, b"v2", "send") + .await + .unwrap(); + let rows: Vec<(String, Option>)> = sqlx::query_as( + "SELECT source, prev_data FROM account_history WHERE address = $1 ORDER BY id", + ) + .bind(&address[..]) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].0, "send"); + assert_eq!(rows[1].1.as_deref(), Some(b"v1".as_ref())); +} + +#[tokio::test] +async fn get_inscription_summary_returns_none_for_unknown_txid() { + let (pool, _container) = setup_pool().await; + let res = get_inscription_summary_by_commit_txid(&pool, &[0xFE; 32]) + .await + .unwrap(); + assert!(res.is_none()); +} + +#[tokio::test] +async fn get_inscription_summary_returns_full_row() { + let (pool, _container) = setup_pool().await; + let commit_txid = [0x12; 32]; + let reveal_txid = [0x34; 32]; + insert_pending_inscription( + &pool, + &commit_txid, + &reveal_txid, + InscriptionKind::Mint, + b"c", + b"ctx", + b"rtx", + 9_001, + ) + .await + .unwrap(); + update_pending_failure_reason(&pool, &commit_txid, "transient esplora 503") + .await + .unwrap(); + + let summary = get_inscription_summary_by_commit_txid(&pool, &commit_txid) + .await + .unwrap() + .expect("row must be returned"); + // Display form: reverse of stored bytes. + let mut display = commit_txid.to_vec(); + display.reverse(); + assert_eq!(summary.commit_txid, hex::encode(display)); + let mut reveal_display = reveal_txid.to_vec(); + reveal_display.reverse(); + assert_eq!( + summary.reveal_txid.as_deref(), + Some(hex::encode(reveal_display).as_str()) + ); + assert_eq!(summary.kind, InscriptionKind::Mint); + assert_eq!(summary.status, PENDING_STATUS_CONSTRUCTED); + assert_eq!(summary.commit_output_value, 9_001); + assert_eq!( + summary.failure_reason.as_deref(), + Some("transient esplora 503") + ); + // Timestamps formatted via to_char — same shape on both columns. + assert!(summary.created_at.ends_with('Z')); + assert!(summary.updated_at.ends_with('Z')); +} diff --git a/node/src/router_tests.rs b/node/src/router_tests.rs index b8fd1d72..3d7a782f 100644 --- a/node/src/router_tests.rs +++ b/node/src/router_tests.rs @@ -5272,3 +5272,210 @@ async fn mint_handler_two_sequential_mints_with_different_recipients_advance_cle "on-disk mmr_root_index must have 2 entries after two successful atomic txs" ); } + +// ======================================================================= +// Coverage tests for GET /api/inscriptions/:txid (added in #113). +// ======================================================================= + +mod inscriptions_endpoint_tests { + use super::*; + use crate::db::{connect_and_migrate, insert_pending_inscription, InscriptionKind}; + use crate::router::create_router; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + async fn live_pool_router() -> ( + Router, + Arc, + testcontainers::ContainerAsync, + ) { + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("postgres container"); + let host = container.get_host().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new(connect_and_migrate(&url).await.expect("migrate")); + let state = live_test_state(pool.clone()); + let app = create_router(state); + (app, pool, container) + } + + #[tokio::test] + async fn get_inscription_bad_hex_returns_422() { + let (app, _pool, _c) = live_pool_router().await; + let req = Request::get("/api/inscriptions/zzzz") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn get_inscription_wrong_length_returns_422() { + let (app, _pool, _c) = live_pool_router().await; + let req = Request::get("/api/inscriptions/abcd") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn get_inscription_unknown_txid_returns_404() { + let (app, _pool, _c) = live_pool_router().await; + let unknown = "f".repeat(64); + let req = Request::get(format!("/api/inscriptions/{}", unknown)) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn get_inscription_known_txid_returns_200_with_summary() { + let (app, pool, _c) = live_pool_router().await; + // Plant a row directly via the DB helper. The endpoint accepts + // the display-order (big-endian) hex; we reverse the stored + // little-endian bytes to construct the URL. + let stored_commit: [u8; 32] = [0x42; 32]; + let stored_reveal: [u8; 32] = [0x43; 32]; + insert_pending_inscription( + &pool, + &stored_commit, + &stored_reveal, + InscriptionKind::Mint, + b"c", + b"ctx", + b"rtx", + 777, + ) + .await + .unwrap(); + let mut display = stored_commit.to_vec(); + display.reverse(); + let display_hex = hex::encode(display); + + let req = Request::get(format!("/api/inscriptions/{}", display_hex)) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = http_body_util::BodyExt::collect(resp.into_body()) + .await + .unwrap() + .to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["kind"], "mint"); + assert_eq!(v["status"], "constructed"); + assert_eq!(v["commit_output_value"], 777); + } + + #[tokio::test] + async fn get_inscription_db_error_returns_500() { + let (app, pool, _c) = live_pool_router().await; + // DROP the table out from under the handler so the SELECT fails. + // CASCADE because tx_mining_log / coin_proof_store have FKs to it. + sqlx::query("DROP TABLE pending_inscriptions CASCADE") + .execute(pool.as_ref()) + .await + .unwrap(); + let txid = "0".repeat(64); + let req = Request::get(format!("/api/inscriptions/{}", txid)) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} + +// ======================================================================= +// Coverage test for the username_claim_log fire-and-forget spawn body. +// The existing `claim_username_with_valid_signature` test exercises the +// spawn call site but doesn't wait long enough for the task to complete +// — this test specifically drives the spawn-body code path (line 1766) +// and asserts the row landed. +// ======================================================================= + +#[tokio::test] +async fn claim_username_precheck_reject_persists_log_row() { + use crate::db::connect_and_migrate; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("postgres container"); + let host = container.get_host().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new(connect_and_migrate(&url).await.expect("migrate")); + let state = live_test_state(pool.clone()); + + // Pre-populate the in-memory UsernameStore with a conflicting name + // so the handler's `precheck` rejects the claim → log_claim(false, + // Some(reason)) → tokio::spawn(insert_username_claim_log). + { + let mut store = state.username_store.lock().unwrap(); + let other_addr = zkcoins_program::hash::digest_from_bytes(&[0x11; 32]); + store.commit_after_db("alice".into(), other_addr); + } + + let secp = secp::Secp256k1::new(); + let secret = bitcoin::secp256k1::SecretKey::from_slice(&[0x33; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(b"alice"); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &kp); + + let body = serde_json::json!({ + "username": "alice", + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let app = create_router(state); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + + // Wait for the fire-and-forget tokio::spawn to land the + // username_claim_log row. + for _ in 0..40 { + let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM username_claim_log") + .fetch_one(pool.as_ref()) + .await + .unwrap(); + if count >= 1 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let (success, reject_reason): (bool, Option) = + sqlx::query_as("SELECT success, reject_reason FROM username_claim_log") + .fetch_one(pool.as_ref()) + .await + .unwrap(); + assert!(!success); + assert!(reject_reason.is_some()); +} From ff9dbae093acd56c7b3225601619cea0c752398f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 27 May 2026 02:42:00 +0200 Subject: [PATCH 17/19] test: close remaining coverage gaps (13 lines) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 coverage tightening after the previous test batch lifted coverage from 92.89% to ~99.6%. The remaining 13 lines are all defensive arms that only fire on bogus DB state or DB-down errors: * `audit.rs:61-64` — `headers_to_json` array-grow branch (3+ same header repeats). Added `headers_to_json_third_repeat_pushes_into_existing_array`. * `db.rs:1057-1060` (`load_pending_in_progress`) and `db.rs:1150-1153` (`get_inscription_summary_by_commit_txid`) — Rust-side `InscriptionKind::from_db_str` defence triggered when a row's `kind` is outside the CHECK enum. Added two tests that drop the status+kind CHECK constraints, plant a bogus row, and assert the loader returns `sqlx::Error::Decode`. * `router.rs:1767` — `eprintln!("Failed to persist username_claim_log: …")` inside the fire-and-forget tokio::spawn. Added `claim_username_log_spawn_handles_insert_error` which drops `username_claim_log` table before issuing a precheck-rejected claim; the spawned insert fails, the eprintln arm executes. Verified locally ---------------- * `cargo fmt --all --check` ✓ * `cargo clippy -p node -p shared -- -D warnings` ✓ * `cargo check -p node --tests` ✓ --- node/src/audit_tests.rs | 28 +++++++++++++++ node/src/db_tests.rs | 74 +++++++++++++++++++++++++++++++++++++++ node/src/router_tests.rs | 75 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/node/src/audit_tests.rs b/node/src/audit_tests.rs index b7c1c328..dc2e5975 100644 --- a/node/src/audit_tests.rs +++ b/node/src/audit_tests.rs @@ -72,6 +72,34 @@ fn headers_to_json_collapses_repeated_keys_into_array() { assert_eq!(values, vec!["a=1", "b=2"]); } +/// Three+ repeats exercise the `Some(Value::Array(mut arr)) => arr.push` +/// branch — the second collapse takes the array-grow path, not the +/// `String → Array` promotion path covered above. +#[test] +fn headers_to_json_third_repeat_pushes_into_existing_array() { + let mut headers = axum::http::HeaderMap::new(); + headers.append( + HeaderName::from_static("set-cookie"), + HeaderValue::from_static("a=1"), + ); + headers.append( + HeaderName::from_static("set-cookie"), + HeaderValue::from_static("b=2"), + ); + headers.append( + HeaderName::from_static("set-cookie"), + HeaderValue::from_static("c=3"), + ); + let value = headers_to_json(&headers); + let arr = value + .as_object() + .and_then(|o| o.get("set-cookie")) + .and_then(|v| v.as_array()) + .expect("repeated key rendered as array"); + let values: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect(); + assert_eq!(values, vec!["a=1", "b=2", "c=3"]); +} + /// `buffer_body` MUST never panic — it returns an empty `Bytes` on /// any underlying error. We synthesize a body that fails to collect /// to exercise the `Err(_) => eprintln + empty` arm. diff --git a/node/src/db_tests.rs b/node/src/db_tests.rs index faf3d60a..052ae8f3 100644 --- a/node/src/db_tests.rs +++ b/node/src/db_tests.rs @@ -1157,3 +1157,77 @@ async fn get_inscription_summary_returns_full_row() { assert!(summary.created_at.ends_with('Z')); assert!(summary.updated_at.ends_with('Z')); } + +#[tokio::test] +async fn load_pending_in_progress_rejects_invalid_kind_in_row() { + // The Rust-side `InscriptionKind::from_db_str` defence in + // `load_pending_in_progress` only fires when the DB row contains + // a `kind` value outside the CHECK enum. Drop the CHECK first + // so we can plant a corrupt row, then assert the loader surfaces + // `sqlx::Error::Decode`. + let (pool, _container) = setup_pool().await; + sqlx::query( + "ALTER TABLE pending_inscriptions DROP CONSTRAINT pending_inscriptions_status_check", + ) + .execute(&pool) + .await + .expect("drop status check"); + sqlx::query("ALTER TABLE pending_inscriptions DROP CONSTRAINT pending_inscriptions_kind_check") + .execute(&pool) + .await + .expect("drop kind check"); + sqlx::query( + "INSERT INTO pending_inscriptions \ + (commit_txid, reveal_txid, status, kind, commitment, commit_tx, reveal_tx, commit_output_value) \ + VALUES ($1, $2, 'constructed', 'bogus', $3, $4, $5, 0)", + ) + .bind(&[0x10u8; 32][..]) + .bind(&[0x11u8; 32][..]) + .bind(b"c".to_vec()) + .bind(b"ctx".to_vec()) + .bind(b"rtx".to_vec()) + .execute(&pool) + .await + .expect("plant row"); + + let err = load_pending_in_progress(&pool) + .await + .expect_err("loader must reject bogus kind"); + assert!(matches!(err, sqlx::Error::Decode(_))); +} + +#[tokio::test] +async fn get_inscription_summary_rejects_invalid_kind_in_row() { + // Same defensive branch but inside the single-row lookup used by + // the `GET /api/inscriptions/:txid` handler. + let (pool, _container) = setup_pool().await; + sqlx::query( + "ALTER TABLE pending_inscriptions DROP CONSTRAINT pending_inscriptions_status_check", + ) + .execute(&pool) + .await + .expect("drop status check"); + sqlx::query("ALTER TABLE pending_inscriptions DROP CONSTRAINT pending_inscriptions_kind_check") + .execute(&pool) + .await + .expect("drop kind check"); + let commit_txid = [0x20u8; 32]; + sqlx::query( + "INSERT INTO pending_inscriptions \ + (commit_txid, reveal_txid, status, kind, commitment, commit_tx, reveal_tx, commit_output_value) \ + VALUES ($1, $2, 'constructed', 'bogus', $3, $4, $5, 0)", + ) + .bind(&commit_txid[..]) + .bind(&[0x21u8; 32][..]) + .bind(b"c".to_vec()) + .bind(b"ctx".to_vec()) + .bind(b"rtx".to_vec()) + .execute(&pool) + .await + .expect("plant row"); + + let err = get_inscription_summary_by_commit_txid(&pool, &commit_txid) + .await + .expect_err("summary must reject bogus kind"); + assert!(matches!(err, sqlx::Error::Decode(_))); +} diff --git a/node/src/router_tests.rs b/node/src/router_tests.rs index 3d7a782f..4e94e332 100644 --- a/node/src/router_tests.rs +++ b/node/src/router_tests.rs @@ -5479,3 +5479,78 @@ async fn claim_username_precheck_reject_persists_log_row() { assert!(!success); assert!(reject_reason.is_some()); } + +/// Cover the `eprintln!("Failed to persist username_claim_log: …")` +/// arm at router.rs line 1767. The fire-and-forget spawn calls +/// `insert_username_claim_log` — we DROP the table out from under it +/// so the insert fails and the eprintln line runs. +#[tokio::test] +async fn claim_username_log_spawn_handles_insert_error() { + use crate::db::connect_and_migrate; + use testcontainers::{runners::AsyncRunner, ImageExt}; + use testcontainers_modules::postgres::Postgres; + + let container = Postgres::default() + .with_tag("17") + .start() + .await + .expect("postgres container"); + let host = container.get_host().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@{}:{}/postgres", host, port); + let pool = Arc::new(connect_and_migrate(&url).await.expect("migrate")); + let state = live_test_state(pool.clone()); + + // Pre-stake a conflicting username so the handler hits the + // precheck-reject path and invokes log_claim(false, …) → spawn. + { + let mut store = state.username_store.lock().unwrap(); + let other_addr = zkcoins_program::hash::digest_from_bytes(&[0x55; 32]); + store.commit_after_db("bob".into(), other_addr); + } + + // Drop the username_claim_log table so the spawned insert errs. + sqlx::query("DROP TABLE username_claim_log CASCADE") + .execute(pool.as_ref()) + .await + .expect("drop username_claim_log"); + + let secp = secp::Secp256k1::new(); + let secret = bitcoin::secp256k1::SecretKey::from_slice(&[0x44; 32]).unwrap(); + let public_key = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &secret); + let address: [u8; 32] = Sha256::digest(public_key.serialize()).into(); + let address_hex = hex::encode(address); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let mut hasher = Sha256::new(); + hasher.update(b"zkcoins:claim_username"); + hasher.update(address_hex.as_bytes()); + hasher.update(b"bob"); + hasher.update(now.to_le_bytes()); + let hash: [u8; 32] = hasher.finalize().into(); + let msg = Message::from_digest(hash); + let kp = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret); + let sig = secp.sign_schnorr(&msg, &kp); + + let body = serde_json::json!({ + "username": "bob", + "address": address_hex, + "public_key": public_key.to_string(), + "signature": hex::encode(sig.serialize()), + "timestamp": now, + }); + let req = Request::post("/api/username/claim") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let app = create_router(state); + let resp = app.oneshot(req).await.unwrap(); + // 409 from precheck — the response path doesn't depend on the + // (failed) audit insert. + assert_eq!(resp.status(), StatusCode::CONFLICT); + + // Give the fire-and-forget spawn time to hit the eprintln path. + tokio::time::sleep(std::time::Duration::from_millis(150)).await; +} From ced1ee7c8be9132bcf4a2ec80911702d9014e4e4 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 27 May 2026 09:46:35 +0200 Subject: [PATCH 18/19] test(api_remote): value-bearing field coverage + lockstep error-string check (#122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(api_remote): extend mint/commit/balance/claim roundtrips with value-bearing field assertions Adds five new tests in Section 4 that assert every wallet-app-facing response field by content, not just by presence. Mirrors the existing strong-assertion block in `send_commit_roundtrip_moves_balance` so a server bug returning a placeholder zero-hash or a truncated string fails CI at the API layer instead of in the wallet's integration loop. - mint_response_carries_state_hash_and_coins_root - commit_response_carries_state_hash_and_coins_root - balance_response_carries_username_after_claim - claim_response_carries_address - balance_response_has_no_username_for_unclaimed_wallet The mint and commit tests are written against the expected contract (hash fields populated as 32-byte non-zero hex); the current server sets them to `None`, so the two tests surface that lockstep gap until the server is updated to emit the fields. * test(api_remote): assert structured error envelope on 4xx responses Every 4xx the wallet app consumes MUST deserialise as `{ success: false, error: }` so the client can branch on the failure reason without re-reading the body. This commit: - extends `balance_invalid_hex_returns_422` and `balance_wrong_length_returns_422` with body content assertions (the balance handler uses a different envelope from `handler_error_response`, so the assertion documents that today's body is the bare `BalanceResponse { balance: 0 }` with no `error` field — surfaces any future refactor that swaps shapes) - adds `send_returns_structured_error_envelope` covering the `handler_error_response` shape used by every `/api/send` 4xx path * test(api_remote): lockstep check that server errors match app errorMessages.ts mapping Adds a lockstep test against `app/src/lib/api/errorMessages.ts :: KNOWN_SERVER_ERRORS` so a server-side error rename surfaces in CI instead of degrading wallet UX to `Serverfehler : `. - new constant `APP_KNOWN_ERROR_STRINGS` mirrors the app's 19-entry list (13 from `map_send_coins_error`, 6 from `handler_error_response` call sites) - new test `error_strings_match_known_app_mapping` provokes each reachable string through a single amortised mint: reachable: Unknown account address, Signature verification failed, Request timestamp too old or in the future, prev_commitment_pubkey required for account update, Insufficient funds mismatch (server emits more-specific text): Invalid hex, Invalid address length operator-only / internal-state-only (documented, not provoked): In-coin not present in source's output_coins_root, Source commitment not present in history MMR, Coin is missing commitment, Should provide an inclusion proof, Coin should not exist in coin history tree, Coin should not exist in tree yet, Too many in-coins / out-coins for one transition, prove failed, internal error, Missing signature, Broadcast failed - extends `mint_invalid_hex_address_returns_422`, `mint_wrong_address_length_returns_422`, `send_bad_address_hex_returns_422`, `send_unknown_account_returns_404`, `send_bad_signature_returns_401`, `send_stale_timestamp_returns_401` with body content assertions so each negative-path test is also a per-string contract anchor * feat(api): close 4 contract gaps surfaced by api_remote tests Resolves the four red tests from the field-coverage suite by fixing the node side of each divergence (the app stays as-is for these; the app-side family-matching is a separate PR). N1 mint response: populate account_state_hash + output_coins_root (hex-encoded 32-byte digests) so wallet clients have everything needed to derive prev_commitment_pubkey for the next send without a second GET /api/proof/:id round-trip. N2 commit response: same pair populated in broadcast_commit_and_deliver, so commit-side flows can also pin the resulting state directly. N3 timestamp window: explicit check_timestamp_window helper runs BEFORE verify_send_signature in send/commit/claim handlers, emitting "Request timestamp too old or in the future" as its own 401 instead of collapsing into "Signature verification failed". Clock-skew misconfiguration now surfaces distinctly. N4 missing signature: signed handlers now reject absent signature/timestamp fields with 401 "Missing signature" / "Missing timestamp" upstream of crypto verification. Defence- in-depth Option-arms stay in verify_send_signature. Unit tests in router_tests.rs updated to the new response shape and to the dedicated timestamp string. * test(api_remote): activate Missing signature provocation now that 401 is wired Replaces the inline "unreachable" comment with a live provocation: POST /api/send with signature deliberately omitted now returns 401 with "Missing signature". Mirrors the handler-level gate added in the same PR. Inventory comment updated to match. * test(api_remote): fix two test setups after the auth-order tightening send_bad_address_hex_returns_422: sign the request body so it passes the new "Missing signature"/timestamp gates that fire upstream of the per-field hex validator. The hex parser still rejects "0xZZZZZZ" with 422; the test now exercises the hex branch as intended. error_strings_match_known_app_mapping: the "prev_commitment_pubkey required" branch is the AccountUpdate transition, which is unreachable from a wallet that only received a mint (account.proof is still None → AccountCreation path). Move the string to the documented-only list with router_tests + account_node_tests references; the unit-level coverage is sufficient and saves a publisher-UTXO per CI run. * test(router): cover send_handler stale-timestamp 401 branch Adds a handler-level unit test asserting that POST /api/send with a stale (year-1970) timestamp returns 401 with "Request timestamp too old or in the future", covering router.rs:675-676 which the existing helper-level `check_timestamp_window_*` tests and the live `send_stale_timestamp_returns_401` api_remote test exercise but the coverage-gate nextest pass did not reach. --- node/src/router.rs | 127 ++++-- node/src/router_tests.rs | 157 +++++-- node/src/runtime.rs | 24 +- node/tests/api_remote.rs | 924 ++++++++++++++++++++++++++++++++++++++- 4 files changed, 1166 insertions(+), 66 deletions(-) diff --git a/node/src/router.rs b/node/src/router.rs index 1b361dca..c8f168a0 100644 --- a/node/src/router.rs +++ b/node/src/router.rs @@ -27,20 +27,45 @@ use crate::publisher::EsploraConfig; use crate::username::UsernameStore; use crate::{NETWORK_CONFIG, USERNAME_DOMAIN}; -/// Verify a Schnorr signature over send request fields. -/// Message = SHA256(account_address || recipient || amount || timestamp) -fn verify_send_signature(request: &SendCoinRequest) -> Result<(), &'static str> { - let signature_hex = request.signature.as_deref().ok_or("Missing signature")?; - let timestamp = request.timestamp.ok_or("Missing timestamp")?; - - // Reject requests older than 5 minutes +/// Maximum allowed clock skew between the wallet's signed timestamp +/// and the server's wall clock. Matches the legacy in-helper window +/// extracted into [`check_timestamp_window`] so the existing app +/// behaviour is unchanged. +pub(crate) const MAX_TIMESTAMP_SKEW_SECS: u64 = 300; + +/// Validate that `timestamp` is within [`MAX_TIMESTAMP_SKEW_SECS`] of +/// the server's wall clock. Extracted so signed handlers can run the +/// timestamp gate explicitly BEFORE `verify_send_signature` — emitting +/// the distinct `"Request timestamp too old or in the future"` string +/// the app's `KNOWN_SERVER_ERRORS` table maps. Folding it back into the +/// signature path would collapse both branches to +/// `"Signature verification failed"`, hiding a clock-skew misconfiguration +/// behind a generic crypto failure. +pub(crate) fn check_timestamp_window(timestamp: u64) -> Result<(), &'static str> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - if now.abs_diff(timestamp) > 300 { + if now.abs_diff(timestamp) > MAX_TIMESTAMP_SKEW_SECS { return Err("Request timestamp too old or in the future"); } + Ok(()) +} + +/// Verify a Schnorr signature over send request fields. +/// Message = SHA256(account_address || recipient || amount || timestamp) +/// +/// Callers MUST run [`check_timestamp_window`] first — this helper no +/// longer enforces the freshness window so the handler can surface +/// `"Request timestamp too old or in the future"` as its own status, +/// rather than collapsing it into `"Signature verification failed"`. +/// `request.signature` and `request.timestamp` are also required by the +/// time this helper runs (the handler returns 401 with +/// `"Missing signature"` / `"Missing timestamp"` upstream); the +/// `Option`-shaped `?` arms below stay as defence-in-depth. +fn verify_send_signature(request: &SendCoinRequest) -> Result<(), &'static str> { + let signature_hex = request.signature.as_deref().ok_or("Missing signature")?; + let timestamp = request.timestamp.ok_or("Missing timestamp")?; // Build the message: SHA256(account_address || recipient || amount || timestamp) let mut hasher = Sha256::new(); @@ -624,15 +649,37 @@ async fn send_coin_handler( ) -> impl IntoResponse { println!("Received send post request..."); - // Verify sender signature if provided (graceful: skip if not present for backwards compat) - if request.signature.is_some() { - if let Err(e) = verify_send_signature(&request) { - eprintln!("Signature verification failed: {}", e); - return handler_error_response( - StatusCode::UNAUTHORIZED, - "Signature verification failed", - ); - } + // The pre-PR back-compat shape silently skipped signature + // verification when `request.signature` was absent. That left + // `/api/send` reachable by an unsigned attacker as long as the + // sender address was known to the server — a hard-to-spot + // security regression. Make signature + timestamp mandatory and + // surface the distinct app-known strings so the client's error + // mapping ladders correctly (`"Missing signature"` → + // `"Anfrage ist nicht signiert."`, etc.). + // + // Run the timestamp gate BEFORE the signature crypto so a stale + // request reports `"Request timestamp too old or in the future"` + // rather than collapsing to a generic + // `"Signature verification failed"`. + // signature + timestamp are both load-bearing — the signature is + // computed over the timestamp. Absent timestamp is a malformed + // signed-payload; surface the same `"Missing signature"` string + // since neither half is independently useful and the app only + // maps one error code for this branch. + if request.signature.is_none() || request.timestamp.is_none() { + return handler_error_response(StatusCode::UNAUTHORIZED, "Missing signature"); + } + let timestamp = request + .timestamp + .expect("timestamp presence checked immediately above"); + if let Err(e) = check_timestamp_window(timestamp) { + eprintln!("Timestamp window check failed: {}", e); + return handler_error_response(StatusCode::UNAUTHORIZED, e); + } + if let Err(e) = verify_send_signature(&request) { + eprintln!("Signature verification failed: {}", e); + return handler_error_response(StatusCode::UNAUTHORIZED, "Signature verification failed"); } // Create converted addresses (from_address and to_address) @@ -1238,20 +1285,38 @@ async fn mint_handler( // `mint_handler` passes a single-element `vec![Invoice::new(...)]` // to `prepare_mint`; `send_coins_inner` builds `coin_proofs` with // `out_coins.len() == coin_templates.len() == invoices.len() == 1`, - // so the Ok-arm Vec has length exactly 1 — `pop()` is total. - let proof_id = state.proof_store.add_proof( - coin_proofs - .pop() - .expect("send_coins returns exactly one coin_proof for single-invoice mint"), - ); + // so the Ok-arm Vec has length exactly 1. + // + // Surface the prover's post-mint hash pair on the response. Today + // the wallet client needs `prev_commitment_pubkey` for the next + // send, which it derives from the proof file fetched via + // `GET /api/proof/:id` — but the matching account_state_hash and + // output_coins_root are the same pair the send response carries + // for an ordinary user transition, so emitting them here lets the + // client advance its local snapshot atomically with the mint + // response (one round-trip instead of two). Source: the prover's + // public inputs on the freshly-built coin proof — identical + // derivation to the one `send_coin_handler` performs. + let final_coin_proof = coin_proofs + .pop() + .expect("send_coins returns exactly one coin_proof for single-invoice mint"); + let pis: [zkcoins_program::F; zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] = + final_coin_proof.proof.public_inputs + [..zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] + .try_into() + .expect("Plonky2 Proof emits N_PROOF_DATA_PUBLIC_INPUTS field elements"); + let proof_data = ProofData::from_field_elements(&pis); + let ash_hex = Some(hex::encode(digest_to_bytes(&proof_data.account_state_hash))); + let ocr_hex = Some(hex::encode(digest_to_bytes(&proof_data.output_coins_root))); + let proof_id = state.proof_store.add_proof(final_coin_proof); ( StatusCode::OK, Json(SendCoinResponse { success: true, error: None, proof_id: Some(proof_id), - account_state_hash: None, - output_coins_root: None, + account_state_hash: ash_hex, + output_coins_root: ocr_hex, }), ) } @@ -1658,17 +1723,15 @@ async fn claim_username_handler( .into_response(); } - // Verify timestamp freshness (5 min window) - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - if now.abs_diff(request.timestamp) > 300 { + // Verify timestamp freshness (shared 5 min window with + // `send_coin_handler`). Uses the same string the send path emits so + // the app's `KNOWN_SERVER_ERRORS` mapping ladders identically. + if let Err(e) = check_timestamp_window(request.timestamp) { return ( StatusCode::UNAUTHORIZED, Json(LnurlErrorResponse { status: "ERROR".into(), - reason: "Timestamp too old or in the future".into(), + reason: e.into(), }), ) .into_response(); diff --git a/node/src/router_tests.rs b/node/src/router_tests.rs index 4e94e332..d30a616d 100644 --- a/node/src/router_tests.rs +++ b/node/src/router_tests.rs @@ -728,29 +728,30 @@ fn send_signature_rejects_missing_timestamp() { } #[test] -fn send_signature_rejects_expired_timestamp() { +fn check_timestamp_window_rejects_expired_timestamp() { + // `verify_send_signature` no longer enforces the timestamp window + // — that gate lives in `check_timestamp_window` and is run by the + // handler explicitly so the distinct app-known string surfaces. let old_timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() - 600; // 10 minutes ago - let request = SendCoinRequest { - account_address: "0x".to_string() + &hex::encode([1u8; 32]), - recipient: "0x".to_string() + &hex::encode([2u8; 32]), - amount: 100, - public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - next_public_key: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .parse() - .unwrap(), - prev_commitment_pubkey: None, - signature: Some("ab".repeat(64)), - timestamp: Some(old_timestamp), - }; - let result = verify_send_signature(&request); + let result = crate::router::check_timestamp_window(old_timestamp); assert!(result.is_err()); - assert!(result.unwrap_err().contains("timestamp")); + assert_eq!( + result.unwrap_err(), + "Request timestamp too old or in the future" + ); +} + +#[test] +fn check_timestamp_window_accepts_fresh_timestamp() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(crate::router::check_timestamp_window(now).is_ok()); } #[test] @@ -2931,7 +2932,7 @@ async fn receive_coin_duplicate_returns_success_false() { } #[tokio::test] -async fn send_without_signature_skips_verification_and_proceeds() { +async fn send_without_signature_returns_401_missing_signature() { use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; use bitcoin::secp256k1::PublicKey; let secret_bytes = include_bytes!("../minting_secret.bin"); @@ -2946,8 +2947,12 @@ async fn send_without_signature_skips_verification_and_proceeds() { .unwrap() .public_key; - // signature field omitted entirely -> request.signature is None -> - // the verify_send_signature block is skipped (legacy/back-compat path). + // signature field omitted entirely -> request.signature is None. + // Before the require-signature fix, the handler silently skipped + // signature verification and proceeded with the send — a security + // gap that let an unauthenticated caller spend any known account. + // The handler now rejects with 401 + the app-known + // `"Missing signature"` string. let body = serde_json::json!({ "account_address": "0x".to_string() + &hex::encode(zkcoins_program::hash::digest_to_bytes(&zkcoins_program::types::MINTING_ADDRESS)), "recipient": "0x".to_string() + &hex::encode([1u8; 32]), @@ -2959,10 +2964,91 @@ async fn send_without_signature_skips_verification_and_proceeds() { .header("content-type", "application/json") .body(Body::from(body.to_string())) .unwrap(); - let (status, _) = send_request(req).await; - // Without signature, the handler proceeds to send_coins on the - // minting account (seeded with 1_000_000 in test_state) and returns OK. - assert_eq!(status, StatusCode::OK); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let v: serde_json::Value = serde_json::from_str(&body).expect("body is JSON"); + assert_eq!(v["success"], false); + assert_eq!(v["error"], "Missing signature"); +} + +#[tokio::test] +async fn send_without_timestamp_returns_401_missing_signature() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::PublicKey; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + + // signature present but timestamp omitted: the signed payload is + // incomplete (the signature commits to the timestamp). Collapsed + // into the same `"Missing signature"` response since neither half + // is independently useful and the app maps only one error code. + let body = serde_json::json!({ + "account_address": "0x".to_string() + &hex::encode(zkcoins_program::hash::digest_to_bytes(&zkcoins_program::types::MINTING_ADDRESS)), + "recipient": "0x".to_string() + &hex::encode([1u8; 32]), + "amount": 1, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": "ab".repeat(64), + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let v: serde_json::Value = serde_json::from_str(&body).expect("body is JSON"); + assert_eq!(v["error"], "Missing signature"); +} + +#[tokio::test] +async fn send_with_stale_timestamp_returns_401_request_timestamp() { + use bitcoin::bip32::{ChildNumber, Xpriv, Xpub}; + use bitcoin::secp256k1::PublicKey; + let secret_bytes = include_bytes!("../minting_secret.bin"); + let xpriv = Xpriv::new_master(bitcoin::Network::Signet, secret_bytes).unwrap(); + let secp = secp::Secp256k1::new(); + let pk_0: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 0 }]) + .unwrap() + .public_key; + let pk_1: PublicKey = Xpub::from_priv(&secp, &xpriv) + .derive_pub(&secp, &[ChildNumber::Normal { index: 1 }]) + .unwrap() + .public_key; + + // Stale timestamp: well outside MAX_TIMESTAMP_SKEW_SECS. Signature + // present so the upstream Missing-signature gate passes — the + // request reaches `check_timestamp_window` and trips its dedicated + // 401 branch (router.rs:674-677). Distinct from the "Signature + // verification failed" string that the signature-verify path would + // emit otherwise. + let stale_timestamp: u64 = 1u64; // 1970, definitely > 300s in the past + let body = serde_json::json!({ + "account_address": "0x".to_string() + &hex::encode(zkcoins_program::hash::digest_to_bytes(&zkcoins_program::types::MINTING_ADDRESS)), + "recipient": "0x".to_string() + &hex::encode([1u8; 32]), + "amount": 1, + "public_key": hex::encode(pk_0.serialize()), + "next_public_key": hex::encode(pk_1.serialize()), + "signature": "ab".repeat(64), + "timestamp": stale_timestamp, + }); + let req = Request::post("/api/send") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let (status, body) = send_request(req).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let v: serde_json::Value = serde_json::from_str(&body).expect("body is JSON"); + assert_eq!(v["error"], "Request timestamp too old or in the future"); } #[test] @@ -3871,11 +3957,24 @@ async fn mint_happy_path_broadcasts_and_returns_proof_id() { proof_id > 0, "fresh-state mint must emit a non-zero proof_id" ); - // Per the mint_handler contract, the mint response intentionally - // omits `account_state_hash` and `output_coins_root` (those are - // returned by /api/send instead). - assert!(v["account_state_hash"].is_null()); - assert!(v["output_coins_root"].is_null()); + // The mint response now carries the prover's post-mint + // `(account_state_hash, output_coins_root)` pair so the wallet + // can advance its local snapshot atomically with the mint + // response — same shape as the send response. Both fields are + // 32-byte hex strings extracted from `coin_proofs[0].proof + // .public_inputs` via `ProofData::from_field_elements`. See the + // `mint_response_carries_state_hash_and_coins_root` integration + // test in `node/tests/api_remote.rs` for the contract. + let ash_hex = v["account_state_hash"] + .as_str() + .expect("account_state_hash present on mint response"); + let ash_bytes = hex::decode(ash_hex).expect("account_state_hash is hex"); + assert_eq!(ash_bytes.len(), 32, "account_state_hash must be 32 bytes"); + let ocr_hex = v["output_coins_root"] + .as_str() + .expect("output_coins_root present on mint response"); + let ocr_bytes = hex::decode(ocr_hex).expect("output_coins_root is hex"); + assert_eq!(ocr_bytes.len(), 32, "output_coins_root must be 32 bytes"); // 4. Verify the persistence side-effects of the Ok arm: the // accounts row for the MINTING address was upserted by diff --git a/node/src/runtime.rs b/node/src/runtime.rs index 9b3c3d43..5739532c 100644 --- a/node/src/runtime.rs +++ b/node/src/runtime.rs @@ -24,6 +24,8 @@ use crate::db; use crate::publisher::{create_and_broadcast_inscription, resume_pending_inscriptions}; use crate::router::{lock_or_recover, SendCoinResponse}; use crate::NETWORK_CONFIG; +use shared::ProofData; +use zkcoins_program::hash::digest_to_bytes; use bitcoin::bip32::Xpriv; use shared::ClientAccount; @@ -271,6 +273,22 @@ pub(crate) async fn broadcast_commit_and_deliver( let mut updated_proof = coin_proof; updated_proof.commitment = Some(commitment); + // Extract the prover's post-state hash pair from the stored + // CoinProof's public_inputs so the response carries the same + // (account_state_hash, output_coins_root) the wallet client used + // to build the commitment in the first place. Lets the client + // confirm the server's post-commit snapshot matches what it just + // signed without a second `/api/proof/:id` round-trip. Derivation + // is identical to the one in `mint_handler` and `send_coin_handler`. + let pis: [zkcoins_program::F; zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] = + updated_proof.proof.public_inputs + [..zkcoins_program::circuit::main::N_PROOF_DATA_PUBLIC_INPUTS] + .try_into() + .expect("Plonky2 Proof emits N_PROOF_DATA_PUBLIC_INPUTS field elements"); + let proof_data = ProofData::from_field_elements(&pis); + let ash_hex = Some(hex::encode(digest_to_bytes(&proof_data.account_state_hash))); + let ocr_hex = Some(hex::encode(digest_to_bytes(&proof_data.output_coins_root))); + let recipient = updated_proof.coin.recipient; let snapshot: Option> = { let mut account_node_guard = lock_or_recover(&state.account_node); @@ -282,7 +300,7 @@ pub(crate) async fn broadcast_commit_and_deliver( .map(AccountNode::serialize_account) }; if let Some(bytes) = snapshot { - let addr_bytes = zkcoins_program::hash::digest_to_bytes(&recipient); + let addr_bytes = digest_to_bytes(&recipient); if let Err(e) = db::upsert_account_with_source(&state.pool, &addr_bytes, &bytes, "receive").await { @@ -296,8 +314,8 @@ pub(crate) async fn broadcast_commit_and_deliver( success: true, error: None, proof_id: Some(proof_id), - account_state_hash: None, - output_coins_root: None, + account_state_hash: ash_hex, + output_coins_root: ocr_hex, }), ) } diff --git a/node/tests/api_remote.rs b/node/tests/api_remote.rs index ce899e6f..65dfd9ce 100644 --- a/node/tests/api_remote.rs +++ b/node/tests/api_remote.rs @@ -486,6 +486,19 @@ async fn balance_invalid_hex_returns_422() { .await .expect("GET /api/balance (bad hex)"); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + // The balance handler returns a `BalanceResponse` (not a + // `SendCoinResponse`) on the 422 branches — so the body has + // `balance: 0` and no `error` field. This anchors the contract: + // any future refactor that swaps the body for a `handler_error_response` + // envelope (with an `error: "Invalid hex"` string, matching the + // app's `KNOWN_SERVER_ERRORS`) must update this assertion. + let body: Value = resp.json().await.expect("balance body JSON"); + assert_eq!(body["balance"], 0, "422 balance body must report balance 0"); + assert!( + body.get("error").is_none(), + "balance 422 body must not carry an `error` field today (got {:?})", + body.get("error") + ); } #[tokio::test] @@ -498,6 +511,14 @@ async fn balance_wrong_length_returns_422() { .await .expect("GET /api/balance (short hex)"); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + // Same envelope shape as the invalid-hex branch above. + let body: Value = resp.json().await.expect("balance body JSON"); + assert_eq!(body["balance"], 0, "422 balance body must report balance 0"); + assert!( + body.get("error").is_none(), + "balance 422 body must not carry an `error` field today (got {:?})", + body.get("error") + ); } #[tokio::test] @@ -627,6 +648,17 @@ async fn mint_invalid_hex_address_returns_422() { .await .expect("POST /api/mint bad hex"); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + // The mint handler uses `handler_error_response` for both hex/ + // length failures, so the body is a `SendCoinResponse` envelope + // with `success: false` and a specific `error` string. Asserting + // the EXACT string keeps the lockstep contract honest — the app's + // `KNOWN_SERVER_ERRORS` uses a generic `"Invalid hex"` placeholder + // but the server emits the more-specific `"account_address is not + // valid hex"`. The lockstep inventory test below documents this + // mismatch. + let body: Value = resp.json().await.expect("mint 422 body JSON"); + assert_eq!(body["success"], Value::Bool(false)); + assert_eq!(body["error"], "account_address is not valid hex"); } #[tokio::test] @@ -640,6 +672,17 @@ async fn mint_wrong_address_length_returns_422() { .await .expect("POST /api/mint short addr"); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + // Same envelope as the invalid-hex branch — but with the address- + // length-specific message. The app's `KNOWN_SERVER_ERRORS` lists + // `"Invalid address length"` as a placeholder; the server emits + // `"account_address must be 32 bytes (64 hex chars)"`. See the + // lockstep inventory test below. + let body: Value = resp.json().await.expect("mint 422 body JSON"); + assert_eq!(body["success"], Value::Bool(false)); + assert_eq!( + body["error"], + "account_address must be 32 bytes (64 hex chars)" + ); } #[tokio::test] @@ -659,6 +702,12 @@ async fn send_bad_address_hex_returns_422() { // — this should fail at the hex-decode step (handler-level 422, // not axum-level deserialization 422). let alice = TestWallet::new(); + // Signature/timestamp are present so the request passes the + // "Missing signature" / "Missing timestamp" / timestamp-window gates + // upstream; the test exercises the per-field hex validator that + // runs after the auth gates. + let ts = unix_now(); + let signature = alice.sign_send("0xZZZZZZ", &alice.address_hex(), 1, ts); let body = json!({ "account_address": "0xZZZZZZ", "recipient": alice.address_hex(), @@ -666,8 +715,8 @@ async fn send_bad_address_hex_returns_422() { "public_key": hex::encode(alice.pubkey(0).serialize()), "next_public_key": hex::encode(alice.pubkey(1).serialize()), "prev_commitment_pubkey": Option::::None, - "signature": Option::::None, - "timestamp": Option::::None, + "signature": Some(signature), + "timestamp": Some(ts), }); let resp = http_client() .post(url("/api/send")) @@ -676,6 +725,13 @@ async fn send_bad_address_hex_returns_422() { .await .expect("POST /api/send bad hex"); assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + // Body contract: same `SendCoinResponse` envelope as the mint 422 + // branches. The string is specific (per-field), not the generic + // `"Invalid hex"` listed in the app's `KNOWN_SERVER_ERRORS` — the + // lockstep inventory below tracks the mismatch. + let body: Value = resp.json().await.expect("send 422 body JSON"); + assert_eq!(body["success"], Value::Bool(false)); + assert_eq!(body["error"], "account_address is not valid hex"); } #[tokio::test] @@ -706,6 +762,14 @@ async fn send_unknown_account_returns_404() { .await .expect("POST /api/send unknown account"); assert_eq!(resp.status(), StatusCode::NOT_FOUND); + // Body contract: 404 here is the canonical "Unknown account address" + // path from `map_send_coins_error` in `router.rs`. This is the + // value-bearing half of the lockstep check — the app's + // `KNOWN_SERVER_ERRORS` list is asserted against the live server + // here so a server-side rename surfaces immediately. + let body: Value = resp.json().await.expect("send 404 body JSON"); + assert_eq!(body["success"], Value::Bool(false)); + assert_eq!(body["error"], "Unknown account address"); } #[tokio::test] @@ -729,6 +793,12 @@ async fn send_bad_signature_returns_401() { .await .expect("POST /api/send bad sig"); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + // Body contract: `"Signature verification failed"` is one of the + // app's `KNOWN_SERVER_ERRORS` and the live server must emit the + // exact same string. + let body: Value = resp.json().await.expect("send 401 body JSON"); + assert_eq!(body["success"], Value::Bool(false)); + assert_eq!(body["error"], "Signature verification failed"); } #[tokio::test] @@ -756,6 +826,12 @@ async fn send_stale_timestamp_returns_401() { .await .expect("POST /api/send stale ts"); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + // Body contract: `"Request timestamp too old or in the future"` + // is one of the app's `KNOWN_SERVER_ERRORS` and the live server + // must emit the exact same string. + let body: Value = resp.json().await.expect("send 401 body JSON"); + assert_eq!(body["success"], Value::Bool(false)); + assert_eq!(body["error"], "Request timestamp too old or in the future"); } #[tokio::test] @@ -1242,6 +1318,850 @@ async fn username_claim_resolve_lnurlp_roundtrip() { .is_some_and(|s| !s.is_empty())); } +// --------------------------------------------------------------------------- +// Section 4 — value-bearing field coverage on wallet-app-facing routes +// +// The roundtrip tests above prove the happy path executes end-to-end; +// the tests in this section assert the EXACT shape and content of +// every response field the wallet app reads. A field that ships as +// `null` / `""` / `"0x00...0"` instead of a real value passes +// `.is_some()` but breaks the wallet — the assertions here catch that +// class of regression at the API layer instead of in the wallet's +// integration test loop. +// --------------------------------------------------------------------------- + +/// Field coverage #1 — mint response carries the post-mint +/// commitment fields (`account_state_hash`, `output_coins_root`). +/// +/// **Contract expectation.** The wallet app needs the same SMT-root +/// pair from the mint response that the send response already carries, +/// so its local account snapshot can advance without a second round +/// trip. Mirror of the strong-assertion block in +/// `send_commit_roundtrip_moves_balance:1090-1109`: each hash field +/// MUST be present, decode to exactly 32 bytes of hex, and be non-zero. +/// A shape-only `.is_some()` check would mask a server bug that +/// returned a placeholder zero-hash or a truncated hex string. +/// +/// **Today the mint handler ships these fields as `None`** (see +/// `router::mint_handler`'s tail and the matching `None`s in +/// `runtime::broadcast_commit_and_deliver`), and the response struct +/// serialises them with `skip_serializing_if = Option::is_none`. The +/// test therefore fails against the current server — it is written +/// against the expected contract, not the current implementation, so +/// CI surfaces the gap until the server is updated to populate the +/// fields. See the task brief for the lockstep rationale. +#[tokio::test] +async fn mint_response_carries_state_hash_and_coins_root() { + let client = http_client(); + let alice = TestWallet::new(); + + assert_minting_balance_in_bounds(&client).await; + + let mint_resp = client + .post(url("/api/mint")) + .json(&json!({ + "account_address": alice.address_hex(), + "amount": MINT_AMOUNT, + })) + .send() + .await + .expect("POST /api/mint"); + assert_eq!(mint_resp.status(), StatusCode::OK, "mint must succeed"); + let body: Value = mint_resp.json().await.expect("mint body JSON"); + + assert_eq!( + body["success"], + Value::Bool(true), + "mint success must be true" + ); + let proof_id = body["proof_id"] + .as_u64() + .expect("proof_id present and a u64"); + assert!( + proof_id > 0, + "proof_id must be a positive u64, got {}", + proof_id + ); + + // Value-bearing assertions on the two post-mint hash fields. + // Mirrors the send-response block in + // `send_commit_roundtrip_moves_balance:1090-1109` verbatim — the + // mint client consumes the same pair to advance its local + // account snapshot, so the same shape guarantees apply. + let ash_hex = body["account_state_hash"] + .as_str() + .expect("account_state_hash present on mint response") + .to_string(); + let ash_bytes = hex::decode(&ash_hex).expect("account_state_hash is hex"); + assert_eq!( + ash_bytes.len(), + 32, + "account_state_hash must be 32 bytes (got {})", + ash_bytes.len() + ); + assert!( + ash_bytes.iter().any(|&b| b != 0), + "account_state_hash must be non-zero on a real mint" + ); + + let ocr_hex = body["output_coins_root"] + .as_str() + .expect("output_coins_root present on mint response") + .to_string(); + let ocr_bytes = hex::decode(&ocr_hex).expect("output_coins_root is hex"); + assert_eq!( + ocr_bytes.len(), + 32, + "output_coins_root must be 32 bytes (got {})", + ocr_bytes.len() + ); + assert!( + ocr_bytes.iter().any(|&b| b != 0), + "output_coins_root must be non-zero on a real mint" + ); + + // Balance must land — the proof is fetchable AND the balance is + // credited. Value-bearing check on the side effect. + let observed = poll_balance_at_least(&client, &alice.address_hex(), MINT_AMOUNT).await; + assert!( + observed >= MINT_AMOUNT, + "balance never reached mint amount; got {observed}" + ); +} + +/// Field coverage #2 — commit response carries the post-commit +/// commitment fields (`account_state_hash`, `output_coins_root`). +/// +/// **Contract expectation.** Same as the mint test above — the wallet +/// app needs the SMT-root pair from the commit response so its local +/// account snapshot advances atomically with the broadcast. Each hash +/// field MUST be present, decode to exactly 32 bytes of hex, and be +/// non-zero. The full mint → send → commit pipeline is exercised +/// because the commit step is otherwise unreachable. +/// +/// **Today the commit handler ships these fields as `None`** (see +/// `runtime::broadcast_commit_and_deliver`'s tail). The test is +/// written against the expected contract and fails against the +/// current server until the runtime is updated to populate the +/// fields. +#[tokio::test] +async fn commit_response_carries_state_hash_and_coins_root() { + let client = http_client(); + let alice = TestWallet::new(); + let bob = TestWallet::new(); + + assert_minting_balance_in_bounds(&client).await; + + // ---- Mint ---- + let mint_resp = client + .post(url("/api/mint")) + .json(&json!({ + "account_address": alice.address_hex(), + "amount": MINT_AMOUNT, + })) + .send() + .await + .expect("POST /api/mint"); + assert_eq!(mint_resp.status(), StatusCode::OK, "mint must succeed"); + let mint_body: Value = mint_resp.json().await.expect("mint body JSON"); + let mint_proof_id = mint_body["proof_id"].as_u64().expect("mint proof_id"); + + let _ = poll_balance_at_least(&client, &alice.address_hex(), MINT_AMOUNT).await; + + // ---- Fetch the mint proof for prev_commitment_pubkey ---- + let proof_resp = client + .get(url(&format!("/api/proof/{}", mint_proof_id))) + .send() + .await + .expect("GET mint proof"); + assert_eq!(proof_resp.status(), StatusCode::OK); + let proof_bytes = proof_resp.bytes().await.expect("mint proof bytes"); + let mint_coin_proof: CoinProof = bincode::deserialize(&proof_bytes).expect("decode CoinProof"); + let prev_pk = mint_coin_proof + .commitment + .as_ref() + .expect("mint coin proof has commitment") + .public_key; + + // ---- Send ---- + let amount = SEND_AMOUNT; + let ts = unix_now(); + let signature = alice.sign_send(&alice.address_hex(), &bob.address_hex(), amount, ts); + let send_resp = client + .post(url("/api/send")) + .json(&json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": amount, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": hex::encode(prev_pk.serialize()), + "signature": signature, + "timestamp": ts, + })) + .send() + .await + .expect("POST /api/send"); + assert_eq!(send_resp.status(), StatusCode::OK, "send must succeed"); + let send_body: Value = send_resp.json().await.expect("send body JSON"); + let send_proof_id = send_body["proof_id"].as_u64().expect("send proof_id"); + let ash_hex = send_body["account_state_hash"] + .as_str() + .expect("send body carries account_state_hash") + .to_string(); + let ocr_hex = send_body["output_coins_root"] + .as_str() + .expect("send body carries output_coins_root") + .to_string(); + let ash_bytes = hex::decode(&ash_hex).expect("ash hex"); + let ocr_bytes = hex::decode(&ocr_hex).expect("ocr hex"); + + // ---- Commit ---- + let mut commit_message = Vec::with_capacity(64); + commit_message.extend_from_slice(&ash_bytes); + commit_message.extend_from_slice(&ocr_bytes); + let commit_sig = alice.sign_commit(&commit_message); + let commit_resp = client + .post(url("/api/commit")) + .json(&json!({ + "proof_id": send_proof_id, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "signature": commit_sig, + "message": hex::encode(&commit_message), + })) + .send() + .await + .expect("POST /api/commit"); + assert_eq!(commit_resp.status(), StatusCode::OK, "commit must succeed"); + let commit_body: Value = commit_resp.json().await.expect("commit body JSON"); + + assert_eq!( + commit_body["success"], + Value::Bool(true), + "commit success must be true" + ); + let echoed_proof_id = commit_body["proof_id"] + .as_u64() + .expect("commit proof_id present and a u64"); + assert_eq!( + echoed_proof_id, send_proof_id, + "commit must echo the send proof_id (got {}, sent {})", + echoed_proof_id, send_proof_id + ); + + // Value-bearing assertions on the post-commit hash fields. Same + // contract as the send response (see + // `send_commit_roundtrip_moves_balance:1090-1109`). + let commit_ash_hex = commit_body["account_state_hash"] + .as_str() + .expect("account_state_hash present on commit response") + .to_string(); + let commit_ash_bytes = hex::decode(&commit_ash_hex).expect("commit ash is hex"); + assert_eq!( + commit_ash_bytes.len(), + 32, + "commit account_state_hash must be 32 bytes (got {})", + commit_ash_bytes.len() + ); + assert!( + commit_ash_bytes.iter().any(|&b| b != 0), + "commit account_state_hash must be non-zero" + ); + + let commit_ocr_hex = commit_body["output_coins_root"] + .as_str() + .expect("output_coins_root present on commit response") + .to_string(); + let commit_ocr_bytes = hex::decode(&commit_ocr_hex).expect("commit ocr is hex"); + assert_eq!( + commit_ocr_bytes.len(), + 32, + "commit output_coins_root must be 32 bytes (got {})", + commit_ocr_bytes.len() + ); + assert!( + commit_ocr_bytes.iter().any(|&b| b != 0), + "commit output_coins_root must be non-zero" + ); +} + +/// Field coverage #3 — `/api/balance` carries the claimed username. +/// +/// `BalanceResponse.username` is `Option` with +/// `skip_serializing_if = Option::is_none`. After a successful +/// `/api/username/claim`, querying balance for the claimed address +/// MUST surface the exact (lowercased) username in the response body. +/// The wallet app reads this to render the "@" badge next +/// to a balance figure without making a second round-trip. +#[tokio::test] +async fn balance_response_carries_username_after_claim() { + let client = http_client(); + let caps = fetch_capabilities(&client).await; + // `usernames` is permanent MVP per `fetch_capabilities`, so the + // skip path is unreachable in practice — keep the gate honest in + // case a future feature trim disables it. + if !caps.usernames { + feature_skip!("usernames", "balance_response_carries_username_after_claim"); + } + + let alice = TestWallet::new(); + let username = format!("u_{}", random_suffix()); + let ts = unix_now(); + let signature = alice.sign_username_claim(&alice.address_hex(), &username, ts); + let claim_resp = client + .post(url("/api/username/claim")) + .json(&json!({ + "username": username, + "address": alice.address_hex(), + "public_key": hex::encode(alice.pubkey(0).serialize()), + "signature": signature, + "timestamp": ts, + })) + .send() + .await + .expect("POST /api/username/claim"); + assert_eq!(claim_resp.status(), StatusCode::OK, "claim must succeed"); + + // GET /api/balance and assert the username surfaces. + let bal_resp = client + .get(url(&format!( + "/api/balance?address={}", + alice.address_hex() + ))) + .send() + .await + .expect("GET /api/balance after claim"); + assert_eq!(bal_resp.status(), StatusCode::OK); + let body: Value = bal_resp.json().await.expect("balance body JSON"); + // Server canonicalises usernames to lowercase before persisting, + // so the round-trip must compare against the lowercased form. + let want = username.to_lowercase(); + assert_eq!( + body["username"].as_str(), + Some(want.as_str()), + "balance body must carry the just-claimed username, got {:?}", + body["username"] + ); +} + +/// Field coverage #4 — `/api/username/claim` echoes the claimed +/// address. The roundtrip test asserts `username` only; the wallet +/// app reads BOTH fields (username + address) and uses the echoed +/// address to verify the claim landed on the wallet's own address +/// before persisting locally — a value-bearing assertion on `address` +/// is therefore required. +#[tokio::test] +async fn claim_response_carries_address() { + let client = http_client(); + let caps = fetch_capabilities(&client).await; + if !caps.usernames { + feature_skip!("usernames", "claim_response_carries_address"); + } + let alice = TestWallet::new(); + let username = format!("u_{}", random_suffix()); + let ts = unix_now(); + let signature = alice.sign_username_claim(&alice.address_hex(), &username, ts); + let claim_resp = client + .post(url("/api/username/claim")) + .json(&json!({ + "username": username, + "address": alice.address_hex(), + "public_key": hex::encode(alice.pubkey(0).serialize()), + "signature": signature, + "timestamp": ts, + })) + .send() + .await + .expect("POST /api/username/claim"); + assert_eq!(claim_resp.status(), StatusCode::OK, "claim must succeed"); + let body: Value = claim_resp.json().await.expect("claim body JSON"); + assert_eq!( + body["username"].as_str(), + Some(username.to_lowercase().as_str()), + "claim response must echo the lowercased username, got {:?}", + body["username"] + ); + assert_eq!( + body["address"].as_str(), + Some(alice.address_hex().as_str()), + "claim response must echo the claimed address verbatim, got {:?}", + body["address"] + ); +} + +/// Field coverage #5 — `/api/balance` omits `username` for an unclaimed +/// wallet. `BalanceResponse.username` is `Option` with +/// `skip_serializing_if = Option::is_none`, so an unclaimed account +/// MUST produce a JSON body that either omits the field entirely +/// (preferred) or sets it to `null`. The wallet app's response schema +/// permits both shapes; the assertion fails if the server returns +/// e.g. `""` (empty string) instead, which would render as a phantom +/// empty username in the UI. +#[tokio::test] +async fn balance_response_has_no_username_for_unclaimed_wallet() { + let client = http_client(); + let wallet = TestWallet::new(); + // No claim happens — the wallet is fresh. + let resp = client + .get(url(&format!( + "/api/balance?address={}", + wallet.address_hex() + ))) + .send() + .await + .expect("GET /api/balance for unclaimed wallet"); + assert_eq!(resp.status(), StatusCode::OK); + let body: Value = resp.json().await.expect("balance body JSON"); + assert_eq!( + body["balance"], 0, + "fresh wallet must have zero balance, got {:?}", + body["balance"] + ); + match body.get("username") { + // Preferred: field omitted entirely (`skip_serializing_if` path). + None => {} + // Permitted: explicit `null`. + Some(Value::Null) => {} + // Anything else (empty string, real string) is a contract + // violation — the wallet app would mis-render it. + Some(other) => panic!( + "unclaimed wallet must produce no `username` (or null), got {:?}", + other + ), + } +} + +// --------------------------------------------------------------------------- +// Section 5 — error-envelope contract +// +// Every non-2xx response the wallet app cares about MUST deserialise +// as `{ success: false, error: }`. The error string +// is the lockstep anchor against `app/src/lib/api/errorMessages.ts :: +// KNOWN_SERVER_ERRORS` — if the server renames a string without +// updating the app's mapping, the user-facing message degrades to +// `Serverfehler : `. +// --------------------------------------------------------------------------- + +/// Error contract #6 — every 4xx send body is a structured envelope. +/// +/// Asserts only the SHAPE of the body (`success: false`, `error` +/// non-empty string). The exact string is covered per-error by the +/// extended negative-path tests above and by the lockstep inventory +/// test below. +#[tokio::test] +async fn send_returns_structured_error_envelope() { + // Use the "unknown account" path: a well-formed body with a + // freshly-generated wallet that has never minted. Picked because + // it is the cheapest provocation that exercises the + // `send_coins_error_response` branch (the 422 invalid-hex paths + // go through `handler_error_response`, which has its own envelope + // shape — both are checked by the per-string assertions). + let alice = TestWallet::new(); + let bob = TestWallet::new(); + let amount: u64 = 1; + let ts = unix_now(); + let signature = alice.sign_send(&alice.address_hex(), &bob.address_hex(), amount, ts); + let resp = http_client() + .post(url("/api/send")) + .json(&json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": amount, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": Option::::None, + "signature": Some(signature), + "timestamp": Some(ts), + })) + .send() + .await + .expect("POST /api/send envelope check"); + let status = resp.status(); + assert!(status.is_client_error(), "expected 4xx, got {}", status); + let body: Value = resp.json().await.expect("envelope body must be JSON"); + assert_eq!( + body["success"], + Value::Bool(false), + "envelope must carry success=false, got {:?}", + body["success"] + ); + let error = body["error"] + .as_str() + .expect("envelope must carry an `error` string"); + assert!(!error.is_empty(), "envelope `error` must be non-empty"); +} + +/// The exact set of `error` strings the wallet app's +/// `KNOWN_SERVER_ERRORS` constant (in +/// `app/src/lib/api/errorMessages.ts`) maps from. Kept in alphabetical +/// groups matching the source comment in that file so a diff against +/// the app stays trivial. If the server adds or renames an error +/// string, BOTH this constant and the app's constant must be updated +/// in lockstep — the test below provokes every reachable string and +/// names the unreachable ones explicitly. +const APP_KNOWN_ERROR_STRINGS: &[&str] = &[ + // From `router::map_send_coins_error` — `send_coins` business errors. + "Unknown account address", + "prev_commitment_pubkey required for account update", + "Insufficient funds", + "In-coin not present in source's output_coins_root", + "Source commitment not present in history MMR", + "Coin is missing commitment", + "Should provide an inclusion proof", + "Coin should not exist in coin history tree", + "Coin should not exist in tree yet", + "Too many in-coins for one transition", + "Too many out-coins for one transition", + "prove failed", + "internal error", + // From `router::handler_error_response` call sites. + "Signature verification failed", + "Missing signature", + "Request timestamp too old or in the future", + "Invalid hex", + "Invalid address length", + "Broadcast failed", +]; + +/// Error contract #7 — lockstep with `app/src/lib/api/errorMessages.ts`. +/// +/// Provokes every error string in `APP_KNOWN_ERROR_STRINGS` that is +/// reachable from a black-box HTTP client and asserts the server's +/// `error` body matches verbatim. Strings that depend on the +/// prover / publisher / Bitcoin network being in a specific failure +/// state are documented as comments — those are covered by deterministic +/// unit tests in `node/src/router_tests.rs` (search for +/// `map_send_coins_error`). Mismatches between the app's expected +/// strings and what the server actually emits are also documented: +/// the app lists generic `"Invalid hex"`, `"Invalid address length"`, +/// `"Broadcast failed"` placeholders that the server never emits as-is. +/// +/// This test does ONE full mint up front so the heavier provocations +/// (Insufficient funds, prev_commitment_pubkey, replay) can re-use +/// the same balance without re-paying prove cost — keep new +/// provocations grouped here for the same reason. +#[tokio::test] +async fn error_strings_match_known_app_mapping() { + let client = http_client(); + + // ---- Strings reachable WITHOUT a prior mint ----------------- + + // "Unknown account address" — fresh wallet send. + { + let alice = TestWallet::new(); + let bob = TestWallet::new(); + let ts = unix_now(); + let signature = alice.sign_send(&alice.address_hex(), &bob.address_hex(), 1, ts); + let resp = client + .post(url("/api/send")) + .json(&json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": 1u64, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": Option::::None, + "signature": Some(signature), + "timestamp": Some(ts), + })) + .send() + .await + .expect("send unknown account"); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let body: Value = resp.json().await.expect("body JSON"); + assert_eq!(body["error"], "Unknown account address"); + } + + // "Signature verification failed" — 64 zero bytes as signature. + { + let alice = TestWallet::new(); + let bob = TestWallet::new(); + let resp = client + .post(url("/api/send")) + .json(&json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": 1u64, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": Option::::None, + "signature": Some("00".repeat(64)), + "timestamp": Some(unix_now()), + })) + .send() + .await + .expect("send bad sig"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body: Value = resp.json().await.expect("body JSON"); + assert_eq!(body["error"], "Signature verification failed"); + } + + // "Request timestamp too old or in the future" — stale timestamp. + { + let alice = TestWallet::new(); + let bob = TestWallet::new(); + let stale_ts = unix_now().saturating_sub(600); + let signature = alice.sign_send(&alice.address_hex(), &bob.address_hex(), 1, stale_ts); + let resp = client + .post(url("/api/send")) + .json(&json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": 1u64, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": Option::::None, + "signature": Some(signature), + "timestamp": Some(stale_ts), + })) + .send() + .await + .expect("send stale ts"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body: Value = resp.json().await.expect("body JSON"); + assert_eq!(body["error"], "Request timestamp too old or in the future"); + } + + // "Missing signature" — well-formed send body but signature: null. + // The signed handlers (`send_handler`, `claim_username_handler`) + // reject absent `signature` fields with 401 BEFORE crypto + // verification runs. The matching `"Missing timestamp"` 401 covers + // an absent `timestamp` field. Both gates land before + // `verify_send_signature` so a clock-skew or empty-credential + // misconfiguration surfaces distinctly instead of collapsing into + // `"Signature verification failed"`. + { + let alice = TestWallet::new(); + let body = json!({ + "account_address": alice.address_hex(), + "recipient": TestWallet::new().address_hex(), + "amount": 1u64, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": Option::::None, + "timestamp": unix_now(), + // signature deliberately omitted + }); + let resp = http_client() + .post(url("/api/send")) + .json(&body) + .send() + .await + .expect("send missing signature"); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body: Value = resp.json().await.expect("body JSON"); + assert_eq!(body["error"], "Missing signature"); + } + + // ---- Mismatches: app uses a generic placeholder, server emits a + // more-specific string. Document each here. ----------------- + + // app `"Invalid hex"` vs. server emit (mint hex path). + { + let resp = client + .post(url("/api/mint")) + .json(&json!({"account_address": "not_hex", "amount": 100u64})) + .send() + .await + .expect("mint bad hex"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body: Value = resp.json().await.expect("body JSON"); + let actual = body["error"].as_str().expect("error string"); + assert_eq!( + actual, "account_address is not valid hex", + "server emits a per-field hex error today; app `KNOWN_SERVER_ERRORS` \ + carries the generic `\"Invalid hex\"` — lockstep gap" + ); + } + + // app `"Invalid address length"` vs. server emit (mint length path). + { + let short_addr = format!("0x{}", "ab".repeat(16)); + let resp = client + .post(url("/api/mint")) + .json(&json!({"account_address": short_addr, "amount": 100u64})) + .send() + .await + .expect("mint short addr"); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body: Value = resp.json().await.expect("body JSON"); + let actual = body["error"].as_str().expect("error string"); + assert_eq!( + actual, "account_address must be 32 bytes (64 hex chars)", + "server emits a per-field length error today; app \ + `KNOWN_SERVER_ERRORS` carries the generic \ + `\"Invalid address length\"` — lockstep gap" + ); + } + + // ---- Strings reachable ONLY after a successful mint -------- + // + // The block below is gated on the minting balance — if the + // deploy-dev DEV server is too drained to mint, skip with a clear + // log line instead of failing the whole suite. The provocations + // re-use one mint to keep prove cost amortised. + let alice = TestWallet::new(); + let bob = TestWallet::new(); + + assert_minting_balance_in_bounds(&client).await; + + let mint_resp = client + .post(url("/api/mint")) + .json(&json!({ + "account_address": alice.address_hex(), + "amount": MINT_AMOUNT, + })) + .send() + .await + .expect("POST /api/mint for lockstep block"); + assert_eq!( + mint_resp.status(), + StatusCode::OK, + "mint must succeed for the post-mint lockstep block" + ); + let mint_body: Value = mint_resp.json().await.expect("mint body JSON"); + let mint_proof_id = mint_body["proof_id"].as_u64().expect("mint proof_id"); + let _ = poll_balance_at_least(&client, &alice.address_hex(), MINT_AMOUNT).await; + + // Fetch the mint commitment so we have a valid `prev_commitment_pubkey` + // to pass on the happy-path replay below — and a clear omission to + // trigger the `"prev_commitment_pubkey required for account update"` + // branch. + let proof_resp = client + .get(url(&format!("/api/proof/{}", mint_proof_id))) + .send() + .await + .expect("GET mint proof"); + assert_eq!(proof_resp.status(), StatusCode::OK); + let proof_bytes = proof_resp.bytes().await.expect("mint proof bytes"); + let mint_coin_proof: CoinProof = bincode::deserialize(&proof_bytes).expect("decode CoinProof"); + let prev_pk = mint_coin_proof + .commitment + .as_ref() + .expect("mint coin proof has commitment") + .public_key; + + // "prev_commitment_pubkey required for account update" — covered by + // `router_tests::map_send_coins_error_prev_commitment_pubkey_required_is_400` + // and `account_node_tests::*prev_commitment_pubkey*`. Live-provoking + // it from the HTTP surface needs a second send on a wallet whose + // `account.proof` is already populated — alice has only received a + // mint here, so the inner path takes the AccountCreation branch + // and never reaches the AccountUpdate gate. We could chain a full + // mint→send→commit and then a second send, but the additional + // on-chain cost (publisher UTXO per inscription) outweighs the + // value of duplicating coverage that the unit tests already give. + + // "Insufficient funds" — send MINT_AMOUNT + 1 (one sat over balance). + { + let amount: u64 = MINT_AMOUNT + 1; + let ts = unix_now(); + let signature = alice.sign_send(&alice.address_hex(), &bob.address_hex(), amount, ts); + let resp = client + .post(url("/api/send")) + .json(&json!({ + "account_address": alice.address_hex(), + "recipient": bob.address_hex(), + "amount": amount, + "public_key": hex::encode(alice.pubkey(0).serialize()), + "next_public_key": hex::encode(alice.pubkey(1).serialize()), + "prev_commitment_pubkey": hex::encode(prev_pk.serialize()), + "signature": Some(signature), + "timestamp": Some(ts), + })) + .send() + .await + .expect("send insufficient funds"); + assert_eq!( + resp.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "Insufficient funds must be 422" + ); + let body: Value = resp.json().await.expect("body JSON"); + assert_eq!(body["error"], "Insufficient funds"); + } + + // ---- Strings NOT deterministically reachable from a black-box + // HTTP client. Each is covered by a unit test in + // `node/src/router_tests.rs`; the comments below name the + // reachable path so a future contributor can find it without + // a full repo grep. ---------------------------------------- + // + // "In-coin not present in source's output_coins_root" + // → router_tests::map_send_coins_error_in_coin_not_present + // (reachable from `account_node::send_coins` only when the + // defense-in-depth shim catches a tampered in-coin proof — + // requires a doctored CoinProof on disk; not provoked here) + // + // "Source commitment not present in history MMR" + // → router_tests::map_send_coins_error_source_commitment_missing + // (requires a mint commitment that was somehow removed from + // the MMR between snapshot and prove — race window only) + // + // "Coin is missing commitment" + // → router_tests::map_send_coins_error_coin_missing_commitment + // (requires `receive_coin` with a CoinProof.commitment = None, + // which the router prevents via type — internal-state-only) + // + // "Should provide an inclusion proof" + // → router_tests::map_send_coins_error_should_provide_inclusion_proof + // (server-internal path through prepare_send_coins — none of + // the client-facing routes can pass a missing inclusion proof) + // + // "Coin should not exist in coin history tree" / "Coin should not + // exist in tree yet" + // → router_tests::map_send_coins_error_coin_history_* + // (a double-commit replay would reach these — but the publisher + // side rejects the replay before send_coins sees it; the + // provocation requires direct in-memory mutation that the HTTP + // surface forbids) + // + // "Too many in-coins for one transition" / "Too many out-coins for + // one transition" + // → router_tests::map_send_coins_error_too_many_* + // (`/api/send` accepts one recipient and reads one in-coin per + // sender, so the >8 path is unreachable from the HTTP surface) + // + // "prove failed" + // → router_tests::map_send_coins_error_prove_failed + // (catch-all for any error message ending in "failed" — would + // require the prover binary to fail at runtime; flaky to + // provoke against the live DEV deploy) + // + // "internal error" + // → router_tests::map_send_coins_error_unknown_returns_internal + // (catch-all for any unmapped `send_coins` error — would + // require the server to invent a new error string) + // + // "Missing signature" + // → router_tests::verify_send_signature_missing_signature for the + // helper-level unit; the live provocation in the block above + // exercises the handler-level 401. + // + // "Broadcast failed" + // → operator-only: requires the publisher's broadcast leg to + // fail. The server actually emits + // `"Failed to broadcast commitment inscription on-chain"` on + // this branch (see `runtime::broadcast_commit_and_deliver`), + // so the app's `"Broadcast failed"` is also a lockstep gap + // placeholder rather than an exact-match expectation. + + // ---- Inventory anchor --------------------------------------- + // + // Compile-time guard: the constant above tracks + // `app/src/lib/api/errorMessages.ts :: KNOWN_SERVER_ERRORS` 1:1. + // If the app drops a string, this `assert!` keeps the suite + // honest — the test reads the constant rather than re-listing + // strings so anyone updating the inventory has exactly one place + // to touch in this file. + assert!( + APP_KNOWN_ERROR_STRINGS.len() == 19, + "APP_KNOWN_ERROR_STRINGS length drifted from the app's \ + KNOWN_SERVER_ERRORS — update both in lockstep (got {})", + APP_KNOWN_ERROR_STRINGS.len() + ); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- From 5d1bf352807edcabcb82499edcef4b56ac6cfb63 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 27 May 2026 10:05:59 +0200 Subject: [PATCH 19/19] hotfix(migrations): revert ce4307c SQL comment edits to restore sqlx hash (#125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #117 merged to develop the dfxdev container started crash-looping with `Migrate(VersionMismatch(1))` and the deploy-dev smoke test returned 502 for ~5 min straight before the workflow failed. Root cause: commit ce4307c ("docs(migrations): replace remaining 'server' with 'node' in SQL comments") edited the comment lines in the already-applied migrations `0001_initial.sql` and `0003_pending_inscriptions.sql`. sqlx hashes the migration file content (comments included), so a deployed DB whose `_sqlx_migrations.checksum` reflects the pre-edit text refuses to boot with the post-edit binary. This is the exact same class of issue that PR #95 already had to hotfix (`13155c1 hotfix(migration): revert SQL comment edit to keep sqlx hash stable`) and that `feedback_sqlx_migration_hash` documents. Fix: restore both files to their pre-ce4307c byte-for-byte content. Pure cosmetic revert — the only difference is "node" → "server" in 6 lines of `--` comments. No schema, no logic, no data change. The container's first boot after this lands will match its existing `_sqlx_migrations` row and proceed past the migrate step. If the "node" / "server" vocabulary is eventually wanted in the migration prose, the right move is a NEW migration whose comments use the chosen vocabulary — the old ones must stay frozen for the checksum to match deployed databases. --- node/migrations/0001_initial.sql | 4 ++-- node/migrations/0003_pending_inscriptions.sql | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/node/migrations/0001_initial.sql b/node/migrations/0001_initial.sql index d20ef81d..51be7007 100644 --- a/node/migrations/0001_initial.sql +++ b/node/migrations/0001_initial.sql @@ -1,9 +1,9 @@ --- Initial Postgres schema for the zkCoins node state-layer. +-- Initial Postgres schema for the zkCoins server state-layer. -- -- This migration is part of PR-A1 in the 3-PR Postgres migration -- series (file-based bincode -> Postgres). The schema is installed -- by `db::connect_and_migrate`; nothing here is wired into the --- node bootstrap yet — that happens in PR-A2 (state + latest block) +-- server bootstrap yet — that happens in PR-A2 (state + latest block) -- and PR-A3 (accounts + usernames). -- -- Design notes: diff --git a/node/migrations/0003_pending_inscriptions.sql b/node/migrations/0003_pending_inscriptions.sql index 16c46808..216e8c5c 100644 --- a/node/migrations/0003_pending_inscriptions.sql +++ b/node/migrations/0003_pending_inscriptions.sql @@ -41,7 +41,7 @@ -- instead of a silent state-machine drift. -- * The partial index on `status <> 'complete'` keeps the resumer's -- boot-time scan O(pending) instead of O(total). After enough --- mints this list will be perpetually empty on a healthy node. +-- mints this list will be perpetually empty on a healthy server. CREATE TABLE pending_inscriptions ( id BIGSERIAL PRIMARY KEY,