Skip to content

release: mainnet-hardening bundle - #117

Merged
TaprootFreak merged 20 commits into
developfrom
release/mainnet-hardening
May 27, 2026
Merged

release: mainnet-hardening bundle#117
TaprootFreak merged 20 commits into
developfrom
release/mainnet-hardening

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Aggregator release branch bundling the work done on the Mainnet hardening track. Includes three feature tracks (#113, #114, #115), a repo-wide rename (#121: "server" → "node"/"API"), and a series of post-merge fix-up and coverage-tightening commits that brought the branch through Lint & Build, the full M3 Ultra test suite (300 tests), and the 100% line + function coverage gate.

Scope

Migrations 0006 → 0010

File Adds
0006_inscription_kind.sql pending_inscriptions.kind (mint / send) + wipe pre-existing rows
0007_request_log.sql request_log — full HTTP audit (raw bodies, JSONB headers, status, latency, client_ip)
0008_full_database_trail.sql 10 new tables (esplora_log, error_log, block_log, observed_inscriptions, state_update_log, account_history, username_claim_log, tx_mining_log, coin_proof_store, boot_log) + accounts_history_trigger + pending_inscriptions.{failure_reason, reveal_txid}
0009_schema_polish.sql reveal_txid NOT NULL, block_log.received_at drop, state_update_log.trigger → trigger_source, request_log.client_ip, esplora_log.triggering_request_log_id, trigger reads optional zkcoins.request_log_id GUC
0010_schema_polish_round2.sql BYTEA length CHECKs across the schema, missing FK relations (tx_mining_log / coin_proof_storepending_inscriptions), 4× logical-pair CHECKs, created_at on accounts / latest_block / smt_state / mmr_state, performance indices, enum CHECKs, accounts_history_capture rename, mmr_root_index.leaf_index UNIQUE

After this PR lands, dfxdev runs the full schema on the next deploy; after the matching develop → main Release PR, dfxprd does too. The mempool/backend self-host from DFXServer/server#257 is the matching infra-side change for the Mainnet path.

Persistence stack — what's wired, what's deferred

This release lands schema + critical-path wiring. Explicit follow-ups, with helpers + columns already in the schema:

  • Per-call-site esplora_log instrumentation (UTXO lookup, broadcast, get_tx, get_block_*)
  • eprintln! → log_err! macro replacement (~50 sites)
  • state_update_log write at the two state.update sites (mint_handler Phase-E + scanner callback)
  • coin_proof_store persistence (replaces the in-memory ProofStore)

Merge

This is an aggregator PR with five distinct migrations and multiple feature tracks already squash-merged into the branch. Use "Create a merge commit", NOT squash — squashing collapses the per-migration and per-feature commit messages on develop, breaks git bisect for the individual changes, and erases the linear history the operator runbooks already reference by commit SHA.

CI

All three jobs green on the head SHA:

Job Status
Lint & Build ✅ success
Node + Shared Tests (M3 Ultra, 300 tests) ✅ success
Coverage Gate (100% line + function) ✅ success

ci:full label active; the matching auto-Release-PR (develop → main) inherits the gate.

Retention

The new audit tables (request_log, esplora_log, block_log, account_history, state_update_log, username_claim_log, tx_mining_log, boot_log, error_log) intentionally have no automatic pruning. Operators prune via plain DELETE … WHERE <timestamp_col> < NOW() - INTERVAL '…' — see feedback_zkcoins_migrations_may_wipe for the closed-test-env stance.

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.
…114)

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.
…115)

`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<F>(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.
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.
…riptions/:txid (#113)

* 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<PgPool>` 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<i64>` 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`                             ✓
…r" 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.
@TaprootFreak TaprootFreak changed the title release: mainnet hardening bundle (PRs #114 + #115) release: mainnet hardening bundle (PRs #113 + #114 + #115) May 26, 2026
@TaprootFreak
TaprootFreak marked this pull request as ready for review May 26, 2026 20:44
@TaprootFreak TaprootFreak added the ci:full Trigger heavy CI jobs (Server + Shared Tests + Coverage Gate, ~60-90 min on M3 Ultra) label May 26, 2026
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.
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.
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`                             ✓
…ngth test

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

Resolve conflict in runtime.rs (boot_log block from release branch kept,
"REST server" → "REST API" rename applied).

Apply server → node rename to merge-introduced files:
- node/migrations/0006_inscription_kind.sql, 0007_request_log.sql, 0008_full_database_trail.sql
- node/src/db.rs (4 doc-comments)
- node/src/router.rs (1 doc-comment)
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.
chore: replace "server" with "node"/"API" across the repo
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.
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`).
…ve paths

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`                             ✓
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`                             ✓
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`                             ✓
@TaprootFreak TaprootFreak changed the title release: mainnet hardening bundle (PRs #113 + #114 + #115) release: mainnet-hardening bundle May 27, 2026
…g check (#122)

* 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: <non-empty string> }` 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 <status>: <raw>`.

  - 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.
@TaprootFreak
TaprootFreak merged commit 8e0b691 into develop May 27, 2026
4 checks passed
TaprootFreak added a commit that referenced this pull request May 27, 2026
…hash (#125)

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:full Trigger heavy CI jobs (Server + Shared Tests + Coverage Gate, ~60-90 min on M3 Ultra)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant