diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3f56336 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Build context exclusions for the api image. +# Keep the context small and avoid shipping host build artefacts or stores. + +# Rust build output +target/ + +# VCS +.git/ +.gitignore + +# Local Blossom content-addressed store (operator path; never bake into image) +# Matches common local paths used with ZKCOINS_BLOSSOM_STORE. +data/ +blossom/ +**/blossom-store/ + +# Secrets / local env (if present) +.env +.env.* +*.pem + +# Editor / OS noise +.DS_Store +**/.DS_Store diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..72f105b --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,121 @@ +name: CI + +on: + workflow_dispatch: + + # Target-branch gate: every commit that lands on `develop` (direct push + # or merge) gets its own run. Grouped by commit SHA below so a simultaneous + # `pull_request` synchronize on the same SHA is deduplicated rather than + # queued twice. + push: + branches: [develop] + + # merge_group: required-check runs for merge queue entries (when enabled). + merge_group: + + # CI runs on every pull request regardless of target branch. This + # makes the default safe for stacked PRs (PR-A → PR-B → PR-C where + # each PR's base is the previous PR's branch) and any other workflow + # that opens a PR against a non-`develop` branch — previously such + # PRs were silently skipped because `branches: [develop]` filtered + # them out, and the only fix was to hand-edit ci.yaml on each new + # feature stack. + # + # `ready_for_review` is added so the workflow fires the moment a + # draft PR is marked ready — drafts themselves skip CI via the + # `if:` guard on the job (saves runner time while work is still in + # progress). + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + # Group by commit SHA so: + # - a `push` to develop and a `pull_request` event for the same SHA + # collapse into one in-flight run (`cancel-in-progress`); + # - re-runs of the same commit replace the previous attempt. + # Per-PR "cancel outdated intermediate commits" is not applied: each + # distinct SHA is a separate group (preferred for develop target-branch + # gates and SHA-stable required checks). + group: ci-${{ github.workflow }}-${{ github.sha }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +# Single job on GitHub-hosted Linux: fmt, clippy, build, test. +# No Postgres/testcontainers, no Plonky2 prover, no llvm-cov coverage +# gate, no self-hosted runner — this tree is small enough that the +# local gates fit on `ubuntu-latest` in one job. +# +# No `notify-failure` job: this repository does not hold the Telegram +# bot secrets (`TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID`). Add one — +# modelled on the node's `notify-failure` job — once those secrets +# are provisioned here. +jobs: + lint-and-build: + name: Lint & Build + # Skip on draft PRs. Non-PR events (push, merge_group, workflow_dispatch) + # always run: `github.event.pull_request` is absent there, so the + # `event_name != 'pull_request'` arm keeps them enabled. + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + # The repo pins its toolchain in `rust-toolchain` (a dated nightly, + # with `rustfmt` and `clippy` in `components`). Installing that pin + # keeps a single compiler for format, lint, build and test — nothing + # to drift against a second, explicit channel install. Chosen over + # `dtolnay/rust-toolchain@stable` because the pin file is present; + # without it this step would use the stable action instead of + # inventing a pin. + - name: Install the pinned toolchain (rust-toolchain) + run: | + # `rustup show active-toolchain` installs the pin when the + # directory has a `rust-toolchain` file and no matching + # toolchain is present yet. The `|| rustup toolchain install` + # arm covers the cold case where show exits non-zero before + # the pin is available — install is idempotent; a real failure + # in the subsequent version checks still fails the step. + rustup show active-toolchain || rustup toolchain install + cargo --version + cargo fmt --version + cargo clippy --version + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + # kernel-proto/build.rs invokes `protoc` (via tonic-build / prost-build) + # to compile proto/kernel/v1/kernel.proto. ubuntu-latest does not ship + # protobuf-compiler by default — without this step, fmt is fine but + # clippy/build/test fail with "Could not find `protoc`". + - name: Install protoc (kernel-proto build.rs) + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + + - name: Check formatting + run: cargo fmt --all --check + + # `--all-targets` is intentional: without it clippy lints library + # targets only, so tests and fixture modules are never linted. + # `--all-features` matches the local green suite. + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build + run: cargo build + + - name: Test + run: cargo test --all-features diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ad0046b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1389 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "api" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "bech32", + "bitcoin", + "futures-util", + "http-body-util", + "kernel-proto", + "prost", + "prost-types", + "serde", + "serde_json", + "sha2", + "tokio", + "tonic", + "tonic-types", + "tower", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base58ck" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" +dependencies = [ + "bitcoin_hashes", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bitcoin" +version = "0.32.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0ce8bd5baaa0d303a19915a6d93afed161f528654e42da2a7a97d05c59499a" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative 0.2.2", + "hex_lit", + "secp256k1", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin-units" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2 0.6.5", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kernel-proto" +version = "0.1.0" +dependencies = [ + "prost", + "tonic", + "tonic-build", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +dependencies = [ + "async-trait", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac6f67be712d12f0b41328db3137e0d0757645d8904b4cb7d51cd9c2279e847" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-types" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07439468da24d5f211d3f3bd7b63665d8f45072804457e838a87414a478e2db8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..34a9a6b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,65 @@ +[workspace] +members = [".", "kernel-proto"] +# Lint/test default surface is the api package only — same idea as node CI +# (`-p node -p shared`): the generated kernel-proto crate is not on the +# clippy line. Identity tests for the carried .proto live in api. +default-members = ["."] +resolver = "2" + +[package] +name = "api" +version = "0.1.0" +edition = "2021" +description = "zkCoins public REST API layer" +license = "MIT" +publish = false + +[dependencies] +# tonic 0.13.1 matches zk-coins/node (kernel-proto): last line whose +# tonic-build still owns prost codegen (`compile_protos`). 0.14 moved that +# to tonic-prost-build — two codegen paths for the same .proto are avoided. +# Client features only: no `router` (server add_service). Handler tests use +# a trait double, not an in-process tonic server. +axum = { version = "0.7.9", features = ["json"] } +tokio = { version = "1", features = [ + "rt-multi-thread", + "macros", + "net", + "signal", + "sync", + "time", +] } +tonic = { version = "0.13.1", default-features = false, features = [ + "codegen", + "prost", + "transport", +] } +# Richer-error envelope (`google.rpc.Status` + `ErrorInfo`) — same line as +# zk-coins/node so the API decodes the production wire shape the node packs. +tonic-types = "0.13.1" +prost = "0.13.5" +prost-types = "0.13.5" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +futures-util = "0.3" +async-trait = "0.1" +# SHA-256 for chal / request_hash / chan_bind / address binding (§1.1, §5.1). +sha2 = "0.10" +# BIP-340 Schnorr — same line as zk-coins/node (`bitcoin` → secp256k1). +# Used only for OwnershipProof verification at the API edge. +bitcoin = { version = "0.32.5", default-features = false, features = [ + "std", + "secp-recovery", +] } +# Bech32m for `zk` addresses (§1.7.7); same major as node workspace. +bech32 = "0.11" +# Generated kernel.v1 client stubs — separate crate so default clippy/test +# of `api` does not lint tonic-build output (result_large_err on Status). +kernel-proto = { path = "kernel-proto" } + +[dev-dependencies] +# Same versions as node/Cargo.toml [dev-dependencies] where shared. +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1f72061 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,126 @@ +# Multi-stage Docker build for the zkCoins public REST API layer. +# +# Toolchain pin: `rust-toolchain` at the repo root +# (`channel = "nightly-2026-06-18"`). rustup respects that file and installs +# the right channel when cargo is first invoked — no manual `rustup install`. +# +# Build: +# docker build -t zkcoins/api:local . +# +# Run (no defaults baked into the image — every required var must be set): +# docker run -p 8080:8080 \ +# -e ZKCOINS_BIND_ADDR=0.0.0.0:8080 \ +# -e ZKCOINS_KERNEL_ADDR=http://node:50051 \ +# -e ZKCOINS_FEATURES=wallet,explorer \ +# -e ZKCOINS_PUBLIC_HOST= \ +# -v api_blossom:/data/blossom \ +# zkcoins/api:local +# +# --------------------------------------------------------------------------- +# Boot environment (from src/config.rs + src/main.rs — fail-closed; no image +# defaults for bind/kernel/store). Names, meaning, requiredness: +# +# Pflicht (Variable muss gesetzt sein; leerer Wert wo vermerkt erlaubt): +# +# ZKCOINS_BIND_ADDR +# HTTP listen address as `host:port` (parsed as SocketAddr). +# Required, non-empty. Empty or garbage → start error (ConfigError). +# Codestelle: src/config.rs ENV_BIND / require_present; bind in +# src/main.rs TcpListener::bind(config.bind_addr). +# Convention for local stack / EXPOSE: 0.0.0.0:8080 (not hard-coded +# in the binary — only in operator env). +# +# ZKCOINS_KERNEL_ADDR +# Kernel gRPC target URI (opaque non-empty string, tonic Endpoint). +# Required, non-empty. Bad URI → start error at connect_lazy. +# Codestelle: src/config.rs ENV_KERNEL; dial src/kernel/client.rs +# KernelClient::connect_lazy / src/main.rs connect_lazy. +# +# ZKCOINS_FEATURES +# Comma-separated subset of §6.1 closed feature set: +# wallet, explorer, publisher, lightning_bridge, mail_bridge. +# Variable required; empty string = all features off (allowed). +# Unknown token → start error. Codestelle: src/config.rs ENV_FEATURES. +# +# ZKCOINS_PUBLIC_HOST +# Comma-separated authoritative hostnames for §5.1 chan_bind. +# Variable required; empty string allowed (then OwnershipProof auth +# fails loud — no silent localhost). Never from HTTP Host header. +# Codestelle: src/config.rs ENV_PUBLIC_HOST. +# +# Optional Blossom surface (§7.4) — all-or-nothing: +# +# ZKCOINS_BLOSSOM_STORE +# Filesystem root for the content-addressed store. +# Absent ⇒ Blossom routes unmounted, four discovery keys unadvertised. +# Present-but-empty ⇒ start error (no /tmp default). +# Codestelle: src/config.rs ENV_BLOSSOM_STORE / parse_blossom_config. +# +# When ZKCOINS_BLOSSOM_STORE is set, these companions become Pflicht: +# +# ZKCOINS_BLOSSOM_MAX_BLOB_BYTES +# Advertised upload size limit; strict decimal u64, must be > 0. +# Codestelle: src/config.rs ENV_BLOSSOM_MAX_BLOB_BYTES. +# +# ZKCOINS_BLOSSOM_ALLOWED_OPS +# Comma-separated lowercase-hex 32-byte op pubkeys allowed to upload. +# Variable required when store is set; empty string allowed +# (surface up, every upload 403). Codestelle: ENV_BLOSSOM_ALLOWED_OPS. +# +# Optional (logging only — not process config): +# +# RUST_LOG +# tracing-subscriber EnvFilter. Unset ⇒ "info" in main::init_tracing +# (src/main.rs). Not a silent fallback for bind/kernel/store. +# --------------------------------------------------------------------------- + +FROM rust:bookworm AS builder +WORKDIR /app + +# kernel-proto/build.rs → tonic_build::configure().compile_protos(...) +# needs `protoc` on PATH at compile time (see kernel-proto/build.rs). +# Pin: Debian bookworm package protobuf-compiler 3.21.12-3 +# (https://packages.debian.org/bookworm/protobuf-compiler) — not unversioned +# `latest` and not a floating upstream tag. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + protobuf-compiler=3.21.12-3+deb12u1 \ + && rm -rf /var/lib/apt/lists/* \ + && protoc --version + +# Copy just the toolchain file first so rustup can fetch the right +# channel before the slow source copy. Layer-caches across source-only changes. +COPY rust-toolchain ./ +RUN rustup show + +COPY . . + +RUN cargo build --release -p api + +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates wget \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 zkcoins \ + && useradd --system --uid 10001 --gid zkcoins \ + --home-dir /data --create-home --shell /usr/sbin/nologin zkcoins \ + # Pre-create the Blossom store dir owned by the runtime user so a fresh + # named volume mounted at /data/blossom inherits writable ownership + # (Docker seeds a new volume from the image path; without this the mount + # is root-owned and the non-root process gets EACCES on blob writes). + && mkdir -p /data/blossom \ + && chown zkcoins:zkcoins /data/blossom + +COPY --from=builder /app/target/release/api /usr/local/bin/zkcoins-api + +# No ZKCOINS_* defaults in the image — boot fails closed without operator env. +ENV RUST_LOG=info +WORKDIR /data +USER zkcoins:zkcoins + +# Documented local-stack port (ZKCOINS_BIND_ADDR=0.0.0.0:8080). The binary +# binds only the address from env (src/main.rs); this is not a code default. +EXPOSE 8080 + +ENTRYPOINT ["zkcoins-api"] diff --git a/README.md b/README.md index 698ed2e..88abbbb 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,16 @@ The API layer sits **outward** of the node. It consumes the node's internal **ke > **Status: scaffold.** The API surface is currently served by [`zk-coins/node`](https://github.com/zk-coins/node) directly; this repo will hold the standalone API layer once the kernel RPC contract stabilises. The full design is specified in [§6.1 (kernel and API)](https://docs.zkcoins.com/specification), [§7.5 (REST)](https://docs.zkcoins.com/specification), and [§7.8 (kernel RPC)](https://docs.zkcoins.com/specification). +### Current surface + +- Full §7.5 endpoint inventory (method, capability, feature, kernel RPC): [`docs/rest-surface.md`](docs/rest-surface.md). +- Rust process (`axum` + `tonic 0.13.1` client): **`GET /`**, **`GET /health`**, info/chain reads, the job surface, **attest/grants**, pull/records/account, **`GET /v1/receipts/stream`** (SSE over `SubscribeReceipts`), bootstrap/publish, and optional Blossom. No placeholder routes for unbuilt keys. +- **OwnershipProof** for attest/grants is verified at the API edge (BIP-340, action-bound domain, `chan_bind`, `request_hash`) **before** any kernel call that would consume a challenge nonce. +- **`GET /` discovery follows registration** via `ServedSurface` — only served keys are advertised. Known-but-disabled inventory paths answer `404 feature_disabled`. The 28-key catalogue stays as inventory (no `blossom_delete`; append-only Blossom). +- Kernel contract: carried `proto/kernel/v1/kernel.proto` with SHA-256 identity pin (`src/proto_identity.rs`); REST errors from `ErrorInfo.metadata["http_status"]` only (API-local auth failures use §7.5 `401 unauthorized` directly). +- Codegen lives in the workspace member **`kernel-proto`** (tonic client stubs only). Workspace `default-members = ["."]` keeps default `cargo clippy` / `cargo test` on the **api** package so generated code is not linted. +- Fail-closed env: `ZKCOINS_BIND_ADDR`, `ZKCOINS_KERNEL_ADDR`, `ZKCOINS_FEATURES`, `ZKCOINS_PUBLIC_HOST` (see the inventory doc). Optional Blossom store: `ZKCOINS_BLOSSOM_STORE` (+ max bytes / allowed ops companions). + ## License MIT diff --git a/docs/rest-surface.md b/docs/rest-surface.md new file mode 100644 index 0000000..544227b --- /dev/null +++ b/docs/rest-surface.md @@ -0,0 +1,224 @@ +# Öffentliche REST-Oberfläche — Bestandsaufnahme + +Normative Quelle: `docs-vectors` Spec **v1.2** (`docs/specification.md`), Abschnitte +**§7.5** (Node REST API), **§6.1** (Kernel und API — zwei Grenzen, Feature-Menge), +**§7.8** (Kernel RPC), ergänzt um die in den §7.5-`endpoints`-Schlüsseln genannten +Oberflächen **§7.4** (Blossom), **§7.6** (Publisher-Hand-off) und **§7.7** (Bootstrap). + +Zeilennummern beziehen sich auf `docs-vectors/docs/specification.md` am Stand der +Bestandsaufnahme (Worktree `zk-coins/docs-vectors`). + +## Geschlossene Mengen (normativ) + +| Menge | Werte | Fundstelle | +|---|---|---| +| API-`features` | `{wallet, explorer, publisher, lightning_bridge, mail_bridge}` | §6.1 L2322, L2333–L2341; §7.5 `/v1/info` L2877 | +| `GET /` · `endpoints`-Schlüssel | siehe Tabelle unten (28 geschlossene Keys) | §7.5; Data Permanence (kein `blossom_delete`) | +| Kernel-Prozeduren | siehe §7.8-Tabelle | §7.8 L3138–L3159 | + +**Feature-Semantik (§6.1):** Jedes Feature ist **off**, bis der Operator es einschaltet. +Ein Request gegen ein deaktiviertes Feature **MUST** mit `404 feature_disabled` beantwortet +werden (§7.5 L2866). `lightning_bridge` und `mail_bridge` öffnen **keine** eigenen Pfade in +§7.5 — sie sind Erweiterungen (`/lightning-bridge`, `/mail-bridge`); in der REST-Tabelle +unten erscheinen sie nur dort, wo die Spec sie als Feature nennt, nicht als zusätzliche +§7.5-Routen. + +**Capability:** „Ja“ = OwnershipProof / GrantProof / Pull-Session / Nostr-Auth-Event +erforderlich. „Nein“ = öffentlich bzw. selbstauthentifizierend (Submit) bzw. +permissionless (Publisher-Hand-off). + +**Kernel-RPC:** „API-lokal“ = kein Kernel-Aufruf (§7.5 L2866). Sonst die §7.8-Prozedur +aus der Backs-Spalte (L3138–L3159). Blossom läuft über den Kernel-Store / die Blossom-Ebene +(§7.8 L3490: API erreicht Blobs über Kernel oder öffentlichen `/blossom`-Pfad — **kein** +eigenes `Kernel`-RPC-Verb in der Procedure-Tabelle). + +--- + +## Vollständige Endpunkt-Tabelle + +| # | Method | Path | Capability | Feature | §7.8-Prozedur / API-lokal | Spec-Fundstelle | +|---|---|---|---|---|---|---| +| 1 | `GET` | `/` | Nein | immer (API-Prozess) | **API-lokal** | §7.5 L2874 | +| 2 | `GET` | `/health` | Nein | immer | **API-lokal** | §7.5 L2875 | +| 3 | `GET` | `/health/ready` | Nein | immer | `GetInfo` (ready / ready_reason) | §7.5 L2876; §7.8 L3140 | +| 4 | `GET` | `/v1/info` | Nein | immer | `GetInfo` (+ API baut `features` selbst, §7.8 L3211–L3214) | §7.5 L2877; §7.8 L3140 | +| 5 | `GET` | `/v1/chain/accumulator` | Nein | `explorer` | `GetAccumulator` | §7.5 L2878; §7.8 L3141; Feature §6.1 L2338 | +| 6 | `GET` | `/v1/chain/inscriptions` | Nein | `explorer` | `ListInscriptions` | §7.5 L2879; §7.8 L3142; Feature §6.1 L2338 | +| 7 | `GET` | `/v1/chain/nullifier/` | Nein | `explorer` | `GetNullifierPath` | §7.5 L2880; §7.8 L3143; Feature §6.1 L2338 | +| 8 | `POST` | `/v1/tx` | Nein (Proof selbstauthentifizierend) | `wallet` | `SubmitTransition` | §7.5 L2888, L2884; §7.8 L3144; Feature §6.1 L2337 | +| 9 | `GET` | `/v1/jobs/` | Nein | `wallet` | `GetJob` | §7.5 L2889; §7.8 L3145; Feature §6.1 L2337 | +| 10 | `GET` | `/v1/jobs//stream` | Nein | `wallet` | `StreamJob` | §7.5 L2890; §7.8 L3146; Feature §6.1 L2337 | +| 11 | `POST` | `/v1/jobs//sign` | Nein (Wallet-Signatur) | `wallet` | `SignTransition` | §7.5 L2891; §7.8 L3147; Feature §6.1 L2337 | +| 12 | `POST` | `/v1/jobs//cancel` | Nein | `wallet` | `CancelJob` | §7.5 L2892; §7.8 L3148; Feature §6.1 L2337 | +| 13 | `POST` | `/v1/attest/balance/challenge` | Nein (stellt Challenge aus) | `wallet` | `OpenPullChallenge` (`action = attest_balance`) | §7.5 L2893; §7.8 L3149, L3341–L3345; Feature §6.1 L2337 | +| 14 | `POST` | `/v1/attest/balance` | **Ja** — action-bound OwnershipProof | `wallet` | `AttestBalance` | §7.5 L2894; §7.8 L3158; Feature §6.1 L2337 | +| 15 | `POST` | `/v1/grants/challenge` | Nein (stellt Challenge aus) | `wallet` | `OpenPullChallenge` (`action = issue_grant`) | §7.5 L2895; §7.8 L3149, L3341–L3345; Feature §6.1 L2337 | +| 16 | `POST` | `/v1/grants` | **Ja** — action-bound OwnershipProof (kein GrantProof) | `wallet` | `IssueViewGrant` | §7.5 L2896; §7.8 L3159; Feature §6.1 L2337 | +| 17 | `POST` | `/v1/pull/challenge` | Nein (stellt Challenge aus) | `wallet` | `OpenPullChallenge` | §7.5 L3039; §7.8 L3149; Feature §6.1 L2337 | +| 18 | `POST` | `/v1/pull` | **Ja** — OwnershipProof oder GrantProof | `wallet` | `Pull` | §7.5 L3040; §7.8 L3150; Feature §6.1 L2337 | +| 19 | `GET` | `/v1/record/` | **Ja** — Pull-Session Bearer | `wallet` | `GetRecord` | §7.5 L3041; §7.8 L3151; Feature §6.1 L2337 | +| 20 | `GET` | `/v1/proof/` | **Ja** — Pull-Session Bearer | `wallet` | `GetCoinProof` | §7.5 L3042; §7.8 L3152; Feature §6.1 L2337 | +| 21 | `GET` | `/v1/account/state` | **Ja** — Ownership-Pull-Session (kein Grant) | `wallet` | `GetAccountState` | §7.5 L3043; §7.8 L3153; Feature §6.1 L2337 | +| 22 | `GET` | `/v1/receipts/stream` | **Ja** — Pull-Session Bearer (Ownership oder Grant) | `wallet` | `SubscribeReceipts` | §7.5 L3044, L2953–L2955; §7.8 L3154; Feature §6.1 L2337 | +| 23 | `POST` | `/v1/publish/spendrecord` | Nein (permissionless) | `publisher` | `Publish` | §7.6 L3050–L3054; §7.8 L3155; Feature §6.1 L2339 | +| 24 | `POST` | `/v1/bootstrap/challenge` | Nein (stellt Challenge aus) | `wallet` | `OpenPullChallenge` (`action` entrust/revoke) | §7.7 L3118; §7.8 L3149, L3341–L3344; Feature §6.1 L2337 | +| 25 | `POST` | `/v1/bootstrap/entrust` | **Ja** — OwnershipProof (Entrust-Domain) | `wallet` | `EntrustOperationalBundle` | §7.7 L3119; §7.8 L3156; Feature §6.1 L2337 | +| 26 | `POST` | `/v1/bootstrap/revoke` | **Ja** — OwnershipProof (Revoke-Domain) | `wallet` | `RevokeOperationalBundle` | §7.7 L3120; §7.8 L3157; Feature §6.1 L2337 | +| 27 | `GET` | `/blossom/` | Nein (Ciphertext) | `explorer` (blob fetch) | Blossom-Ebene / Kernel-Store — **kein** eigenes Kernel-RPC-Verb (§7.8 L3490) | §7.4 L2804; Feature §6.1 L2338; `endpoints`-Key §7.5 L2874 | +| 28 | `HEAD` | `/blossom/` | Nein | `explorer` (blob fetch) | Blossom-Ebene / Kernel-Store | §7.4 L2805; Feature §6.1 L2338; Key §7.5 L2874 | +| 29 | `PUT` | `/blossom/upload` | **Ja** — Nostr kind-`24242` Auth-Event | `explorer` / `wallet` | Blossom-Ebene / Kernel-Store | §7.4; Keys §7.5; Data Permanence (append-only, Antwort `{ blob_id }`) | +| 30 | `POST` | `/blossom/upload` | **Ja** — Nostr kind-`24242` Auth-Event | `explorer` / `wallet` | Blossom-Ebene / Kernel-Store (äquivalent zu PUT) | §7.4; Keys §7.5 | + +**Kein** `DELETE /blossom/` — Data Permanence (Requirement 12): der Blob-Store +ist append-only; empfangene Daten werden nie gelöscht. `ReplicaReceiptV1` / §4.6 +Dual-Commit und `retention_hold` entfallen mit der Spec. + +### Geschlossene `endpoints`-Schlüssel von `GET /` (§7.5) + +Genau diese 28 Keys — wörtlich, vollständig: + +| Key | Typischer Pfad | +|---|---| +| `health` | `/health` | +| `health_ready` | `/health/ready` | +| `info` | `/v1/info` | +| `chain_accumulator` | `/v1/chain/accumulator` | +| `chain_inscriptions` | `/v1/chain/inscriptions` | +| `chain_nullifier` | `/v1/chain/nullifier/` | +| `tx` | `/v1/tx` | +| `jobs` | `/v1/jobs/` | +| `jobs_stream` | `/v1/jobs//stream` | +| `jobs_sign` | `/v1/jobs//sign` | +| `jobs_cancel` | `/v1/jobs//cancel` | +| `attest_balance_challenge` | `/v1/attest/balance/challenge` | +| `attest_balance` | `/v1/attest/balance` | +| `grants_challenge` | `/v1/grants/challenge` | +| `grants` | `/v1/grants` | +| `pull_challenge` | `/v1/pull/challenge` | +| `pull` | `/v1/pull` | +| `record` | `/v1/record/` | +| `proof` | `/v1/proof/` | +| `account_state` | `/v1/account/state` | +| `receipts_stream` | `/v1/receipts/stream` | +| `publish_spendrecord` | `/v1/publish/spendrecord` | +| `bootstrap_challenge` | `/v1/bootstrap/challenge` | +| `bootstrap_entrust` | `/v1/bootstrap/entrust` | +| `bootstrap_revoke` | `/v1/bootstrap/revoke` | +| `blossom_get` | `/blossom/` | +| `blossom_head` | `/blossom/` | +| `blossom_upload` | `/blossom/upload` | + +Spec-Regel (§7.5): Ein Producer emittiert **genau** die geschlossene Schlüsselmenge +für die Oberflächen, die dieses Deployment exponiert, und **MUST** Keys für nicht +beworbene optionale Rollen weglassen. Unbekannte Keys beim Lesen ignorieren. + +--- + +## Zählung (Kurzform) + +| Kategorie | Anzahl | +|---|---| +| HTTP-Endpunkte (Method+Path) in der Tabelle oben | **30** | +| davon in §7.5-Haupttext (ohne §7.4/§7.6/§7.7) | **22** | +| + Publisher §7.6 | **1** | +| + Bootstrap §7.7 | **3** | +| + Blossom §7.4 (GET/HEAD/PUT/POST; kein DELETE) | **4** | +| Geschlossene `endpoints`-Keys | **28** | +| Capability-gebunden (Ownership / Grant / Session / Nostr-Auth) | **12** (#14, #16, #18–22, #25–26, #29–30) | +| Challenge-Aussteller ohne Capability | **4** (#13, #15, #17, #24) | +| API-lokal | **2** (`GET /`, `GET /health`) | + +### Pro Feature (Method+Path, ohne „immer“) + +| Feature | Endpunkte | Nummern | +|---|---|---| +| immer (API-Prozess) | 4 | #1–#4 | +| `wallet` | 19 | #8–#22, #24–#26 (+ Blossom-Upload geteilt) | +| `explorer` | 3 Chain + Blossom-Fetch (+ Upload geteilt) | #5–#7, #27–#28 (+ #29–#30 geteilt) | +| `publisher` | 1 | #23 | +| `lightning_bridge` | 0 in §7.5 | Erweiterung `/lightning-bridge` | +| `mail_bridge` | 0 in §7.5 | Erweiterung `/mail-bridge` | + +Blossom-Upload (#29–#30) sind weder rein `wallet` noch rein `explorer` in der +Feature-Tabelle §6.1; sie gehören zur öffentlichen Blossom-Ebene (§7.4) und werden von +Deployments mit Wallet- und/oder Explorer-Rolle benötigt (Blob-Pfad). + +--- + +## Implementierungsstand dieses Repos + +| Endpunkt | Status | +|---|---| +| `GET /health` | **implementiert** — `200` mit Body `"ok"` | +| `GET /health/ready` | **implementiert** — Readiness aus Kernel-`GetInfo` (`ready` / `ready_reason`); Body-Form `{ ready, reason? }`, nie die generische Fehlerform. Bei fehlgeschlagenem `GetInfo` (z. B. fehlende `ChainIdentity` im node): **503** `{ ready: false, reason: "dependency_unavailable" }` — nie grünes `ready: true`. | +| `GET /` | **implementiert** — `{ name, version, endpoints }` mit **genau** den Flächen, die dieser Prozess registriert (`ServedSurface`). Inventur der 28 Keys in `CLOSED_ENDPOINT_KEYS`; unregistrierte Keys werden weggelassen. | +| `GET /v1/info` | **implementiert** — Kernel-`GetInfo` + API-eigene `features` aus `ZKCOINS_FEATURES` (`kernel_parts` bleibt intern). | +| `GET /v1/chain/accumulator` | **implementiert** — `GetAccumulator`; `root` ist pass-through der Kernel-`nav_root`, keine Nachrechnung. | +| `GET /v1/chain/inscriptions` | **implementiert** — `ListInscriptions` (Server-Stream → eine Seite); Triple-Cursor ganz-oder-gar-nicht; leerer Katalog → leere Liste (kein 404). | +| `GET /v1/chain/nullifier/` | **implementiert** — `GetNullifierPath`; `present`/`absent` bleiben getrennt; Kernel-`internal_error` wird **nicht** als absent umgeschrieben. | +| `POST /v1/tx` | **implementiert** — `SubmitTransition` | +| `GET /v1/jobs/{job_id}` | **implementiert** — `GetJob` | +| `GET /v1/jobs/{job_id}/stream` | **implementiert** — `StreamJob` als SSE | +| `POST /v1/jobs/{job_id}/sign` | **implementiert** — `SignTransition` | +| `POST /v1/jobs/{job_id}/cancel` | **implementiert** — `CancelJob` | +| `POST /v1/attest/balance/challenge` | **implementiert** — `OpenPullChallenge` (`action = attest_balance`) | +| `POST /v1/attest/balance` | **implementiert** — OwnershipProof-Verifikation am API-Rand, dann `AttestBalance` | +| `POST /v1/grants/challenge` | **implementiert** — `OpenPullChallenge` (`action = issue_grant`) | +| `POST /v1/grants` | **implementiert** — OwnershipProof-Verifikation am API-Rand, dann `IssueViewGrant` | +| `POST /v1/pull/challenge` | **implementiert** — `OpenPullChallenge` (`action = pull`) | +| `POST /v1/pull` | **implementiert** — OwnershipProof am API-Rand, dann `Pull` (GrantProof fail-closed) | +| `GET /v1/record/` | **implementiert** — `GetRecord` (Bearer-Session) | +| `GET /v1/proof/` | **implementiert** — `GetCoinProof` (Bearer-Session) | +| `GET /v1/account/state` | **implementiert** — `GetAccountState` (Ownership-Session) | +| `GET /v1/receipts/stream` | **implementiert** — `SubscribeReceipts` als SSE (Ownership- **oder** Grant-Session; 401/410-Trennung wie Proof) | +| `POST /v1/bootstrap/challenge` | **implementiert** — `OpenPullChallenge` (`action = entrust` \| `revoke`) | +| `POST /v1/bootstrap/entrust` | **implementiert** — OwnershipProof (Entrust-Domain) + Bundle-Längenprüfung (161 B), dann `EntrustOperationalBundle`; Bundle wird nie geloggt | +| `POST /v1/bootstrap/revoke` | **implementiert** — OwnershipProof (Revoke-Domain), dann `RevokeOperationalBundle` | +| `POST /v1/publish/spendrecord` | **implementiert** — `Publish`; Ablehnung → HTTP 200 `{accepted:false, reason}`; v1-Fee-Felder → 400 | +| `GET`/`HEAD /blossom/`, `PUT`/`POST /blossom/upload` | **implementiert** wenn `ZKCOINS_BLOSSOM_STORE` gesetzt — API-lokaler append-only Store (§7.4 / Data Permanence); kein Kernel-RPC; ohne Store unregistriert; **kein** DELETE | +| alle übrigen Method+Path | **nicht registriert** — kein Handler, kein `todo!()`, kein Platzhalter | + +**Bewusst nicht beworben:** + +| Key | Warum | +|---|---| +| `blossom_*` (ohne `ZKCOINS_BLOSSOM_STORE`) | §7.4; die drei Schlüssel (`get`/`head`/`upload`) werden **nur** advertised, wenn der inhaltsadressierte Store konfiguriert ist. | +| `blossom_delete` | Data Permanence — existiert nicht mehr in der Inventur. | + +Router und Discovery teilen eine Quelle (`ServedSurface` in `src/routes.rs`): die +aktive Mengen folgt `Config::features` und dem Blossom-Store; eine neue +registrierte Fläche erscheint automatisch in `GET /`; deaktivierte Features +sind unregistriert und unbeworben (fail-closed, §7.5). Path-Parameter in +Discovery/`CLOSED_ENDPOINT_KEYS` nutzen die Spec-Schreibweise `` +(Axum-Matcher: `:name`). + +gRPC: getragenes `proto/kernel/v1/kernel.proto` (Identität per SHA-256-Pin + +Sibling-Vergleich mit `zk-coins/node`), Client `tonic 0.13.1`, Fehlerübersetzung +ausschließlich über `google.rpc.ErrorInfo` (`domain`, `reason`, +`metadata["http_status"]`) — keine zweite Status-Tabelle im api. + +### Dokumentierte Lücken + +| Lücke | Warum | +|---|---| +| — | Feature-Gating (§6.1 / §7.5) ist aktiv: `ServedSurface::active` filtert nach `ZKCOINS_FEATURES` + Blossom-Store; deaktivierte Flächen sind unregistriert (HTTP 404) und fehlen in `GET /`. | +| — | Data Permanence: Blossom ist append-only (`PUT`/`POST`/`GET`/`HEAD` only); Upload → `{ blob_id }` ohne `receipt`; kein `retention_hold`, kein Orphan-Prune. | + +--- + +## Pflicht-Umgebungsvariablen (fail-closed) + +| Variable | Bedeutung | +|---|---| +| `ZKCOINS_BIND_ADDR` | Socket-Adresse für den HTTP-Listener (z. B. `127.0.0.1:8080`). **Kein Default.** | +| `ZKCOINS_KERNEL_ADDR` | Adresse des Kernel-gRPC (z. B. `http://127.0.0.1:50051`). **Kein Default.** Pflicht, auch wenn dieser Scaffold den Kanal noch nicht öffnet — Start ohne konfigurierte Kernel-Adresse ist unzulässig. | +| `ZKCOINS_FEATURES` | Komma-separierte Teilmenge von `{wallet,explorer,publisher,lightning_bridge,mail_bridge}`. Darf leer sein (alle Features off). Unbekannter Token → **Startfehler**. Variable selbst ist Pflicht (explizit leer = absichtlich nichts freigeschaltet). | +| `ZKCOINS_PUBLIC_HOST` | Komma-separierte autoritative Hostnamen für §5.1 `chan_bind` (lowercase, trailing-dot gestrichen). **Nie** aus `Host`-Header. Darf leer sein (dann schlägt OwnershipProof-Auth laut fehl). Variable selbst ist Pflicht. | + +### Optionale Blossom-Fläche (§7.4) + +| Variable | Bedeutung | +|---|---| +| `ZKCOINS_BLOSSOM_STORE` | Wurzelverzeichnis des inhaltsadressierten Blob-Stores. **Abwesend** ⇒ die drei Blossom-Keys (`get`/`head`/`upload`) bleiben unbeworben und unmontiert. **Kein Default-Pfad**, kein `/tmp`-Rückfall. Leer gesetzt → Startfehler. | +| `ZKCOINS_BLOSSOM_MAX_BLOB_BYTES` | Pflicht-Begleiter wenn der Store gesetzt ist: ausgewiesene Upload-Obergrenze (`> 0`). Body darüber → `413 payload_too_large`. | +| `ZKCOINS_BLOSSOM_ALLOWED_OPS` | Pflicht-Begleiter wenn der Store gesetzt ist: komma-separierte lowercase-hex-32B-`op`-Pubkeys (gepaarte Konten + Replikations-Peers). Darf leer sein (dann ist jeder Upload `403`). | diff --git a/kernel-proto/Cargo.toml b/kernel-proto/Cargo.toml new file mode 100644 index 0000000..a6e3131 --- /dev/null +++ b/kernel-proto/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "kernel-proto" +version = "0.1.0" +edition = "2021" +description = "Generated kernel.v1 gRPC types and client stubs (no business logic)." +publish = false + +[dependencies] +# Matches zk-coins/node kernel-proto tonic line: last line whose tonic-build +# still owns prost codegen (`compile_protos`). Client-only: no `router` +# (Server::add_service) — the api is a pure gRPC client. +tonic = { version = "0.13.1", default-features = false, features = [ + "codegen", + "prost", + "transport", +] } +prost = "0.13.5" + +[build-dependencies] +tonic-build = "0.13.1" diff --git a/kernel-proto/build.rs b/kernel-proto/build.rs new file mode 100644 index 0000000..49f89f9 --- /dev/null +++ b/kernel-proto/build.rs @@ -0,0 +1,26 @@ +//! Compile the workspace-owned `kernel.v1` contract into tonic/prost stubs. +//! +//! The `.proto` lives at the workspace root under `proto/kernel/v1/kernel.proto` +//! (copied from zk-coins/node; identity is enforced by a unit test in the +//! **api** package). Paths are anchored at `CARGO_MANIFEST_DIR` so the build +//! is cwd-independent. + +use std::env; +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); + let proto = manifest_dir.join("../proto/kernel/v1/kernel.proto"); + let include = manifest_dir.join("../proto"); + + println!("cargo:rerun-if-changed={}", proto.display()); + + // Pure client: the api never hosts a kernel service. In-process handler + // tests use a trait double (`KernelRpc`), not generated server stubs. + tonic_build::configure() + .build_server(false) + .build_client(true) + .compile_protos(&[proto], &[include])?; + + Ok(()) +} diff --git a/kernel-proto/src/lib.rs b/kernel-proto/src/lib.rs new file mode 100644 index 0000000..adca103 --- /dev/null +++ b/kernel-proto/src/lib.rs @@ -0,0 +1,24 @@ +//! Generated `kernel.v1` types and gRPC **client** stubs. +//! +//! This crate contains **only** `tonic`/`prost` output from the workspace +//! `proto/kernel/v1/kernel.proto`. No business logic, no API state, no +//! validation beyond what prost generates. +//! +//! Normative contract: specification §7.8. The carried `.proto` is pinned by +//! content hash in the **api** package (`api::proto_identity`). +//! +//! # Clippy +//! +//! Generated code trips lints such as `result_large_err` (`tonic::Status` is +//! large). Fighting the generator is pointless, and the findings say nothing +//! about hand-written code — this crate must stay generator-only. Clippy is +//! therefore silenced at the crate root (`#![allow(clippy::all)]`), matching +//! the node `kernel-proto` pattern. If hand-written logic is ever added here, +//! the allow no longer applies and must be removed. + +// Generated code trips several clippy lints; silence them at the crate +// root rather than fighting the generator. +#![allow(clippy::all)] +#![allow(missing_docs)] + +tonic::include_proto!("kernel.v1"); diff --git a/proto/kernel/v1/kernel.proto b/proto/kernel/v1/kernel.proto new file mode 100644 index 0000000..dd5e13e --- /dev/null +++ b/proto/kernel/v1/kernel.proto @@ -0,0 +1,334 @@ +// kernel.v1 — normative kernel RPC contract extracted from +// docs/specification.md §7.8 (tag spec-v1.2, package kernel.v1). +// Source of truth: the ```proto block under +// "kernel.v1 message contract (normative)". Do not invent fields. +// Fixed-width bytes (32B digests, 64B signatures) are checked in the +// implementation; proto3 has no fixed-length type. Closed string +// value sets stay as strings here (as in the normative block), not +// as enums. google.rpc.Status / google.rpc.ErrorInfo are used for +// errors and are not re-declared in this file. +// +// Unbounded scope sentinels (§5.1 / §5.2 / §7.5 / Scope below): +// asset_ids = "*" ⇔ Scope.all_assets = true (asset_ids empty) +// not_before = 0 — no lower bound +// not_after = 9223372036854775807 (2⁶³−1) — no upper bound +// Proto3 scalar zero defaults resolve to the same pair; do not +// introduce `optional` on not_before/not_after to invent a second +// "unbounded" encoding. + +syntax = "proto3"; +package kernel.v1; + +service Kernel { + rpc GetInfo(GetInfoRequest) returns (Info); + rpc GetAccumulator(GetAccumulatorRequest) returns (AccumulatorTip); + rpc ListInscriptions(ListInscriptionsRequest) returns (stream Inscription); + rpc GetNullifierPath(NullifierPathRequest) returns (NullifierPath); + rpc SubmitTransition(TransitionRequest) returns (JobHandle); + rpc GetJob(JobRequest) returns (Job); + rpc StreamJob(JobRequest) returns (stream JobEvent); + rpc SignTransition(SignRequest) returns (Job); + rpc CancelJob(JobRequest) returns (Job); + rpc OpenPullChallenge(PullChallengeRequest) returns (Challenge); + rpc Pull(PullRequest) returns (PullResult); + rpc GetRecord(RecordRequest) returns (RecordBlob); + rpc GetCoinProof(CoinProofRequest) returns (CoinProofBlob); + rpc GetAccountState(AccountStateRequest) returns (AccountStateResult); + rpc SubscribeReceipts(SubscribeReceiptsRequest) returns (stream Receipt); + rpc Publish(PublishRequest) returns (PublishResult); + rpc EntrustOperationalBundle(EntrustRequest) returns (EntrustResult); + rpc RevokeOperationalBundle(RevokeRequest) returns (RevokeResult); + rpc AttestBalance(AttestRequest) returns (JobHandle); + rpc IssueViewGrant(GrantRequest) returns (GrantResult); +} + +message GetInfoRequest {} +message Info { + string network = 1; // exactly one of "mainnet" | "testnet" | "regtest" — 1:1 to the §2.2 tags + // (Bitcoin network is pinned 1:1 to this tag; no separate bitcoin_network field) + reserved 2; // was bitcoin_network; removed (v1: bitcoin_network == network always) + string protocol_version = 3; // "v1" + map circuit_digests = 4; // {"C": 32B, "C_balance": 32B} (§1.7.9) + string relay_url = 5; + string blossom_url = 6; + uint32 finality_confirmations = 7; // 6 (§3.9) + uint32 max_tx_inputs = 8; // §2.5 bounds + uint32 max_tx_outputs = 9; + uint32 max_rx_coins = 10; + uint32 max_account_assets = 11; + bool ready = 12; // backs /health/ready + uint64 bitcoin_tip_height = 13; + bytes accumulator_root = 14; // = nav_root (§3.7) + uint64 scanner_lag = 15; + uint64 max_blob_bytes = 16; // §7.4 Blossom advertised size limit + uint64 activation_height = 17; // pinned per-network scan origin (§3.6) + BootstrapManifest bootstrap = 18; // §4.3 global infrastructure only + repeated string kernel_parts = 19; // which kernel parts this kernel runs: each ∈ + // {"scanner","prover","publisher"}. NOT the §7.5 /v1/info + // `features` array — that is API-layer configuration the API + // owns and constructs itself, and the kernel cannot know it. + optional string ready_reason = 20; // set iff ready == false; closed set (§7.5 /health/ready): + // "syncing" | "scanner_lag" | "circuit_mismatch" | "deep_reorg" + // | "dependency_unavailable" + bytes bootstrap_pubkey = 21; // 32B x-only; pinned network-parameter trust anchor for BootstrapManifest (§3.6, §4.3) +} +message BootstrapManifest { + string network = 1; + string protocol_version = 2; // "v1" + repeated string seed_relays = 3; + repeated string blob_stores = 4; + repeated bytes operator_ids = 5; // 32B x-only each + uint64 issued_at = 6; + uint64 expires_at = 7; + bytes manifest_sig = 8; // 64B BIP-340 +} + +message GetAccumulatorRequest {} +message AccumulatorTip { bytes root = 1; bytes tip_block_hash = 2; uint64 tip_height = 3; uint64 size = 4; } // root = nav_root = Hc("NfLog/Root", size ‖ mth) (§3.7) + +message ListInscriptionsRequest { + // Defaults (API-normalised before RPC when the REST query omits them, §7.5): from_height = 0, + // from_tx_index = 0, from_vin_index = 0, limit = 100. Valid limit ∈ 1..1000; 0 or >1000 → + // INVALID_ARGUMENT / HTTP 400 bounds_exceeded. + // Proto3: optional so absence is distinguishable from zero; a caller that sets limit = 0 is rejected. + // Inclusive lexicographic lower bound on (height, tx_index, vin_index); REST response carries + // next_height + next_tx_index + next_vin_index as the exclusive triple-cursor (§7.5) — all three + // together or all three absent. The kernel stream itself yields Inscription messages in stable + // (height, tx_index, vin_index) sort order (then §3.6 payload-member order inside one inscription). + optional uint64 from_height = 1; + optional uint32 limit = 2; + optional uint64 from_tx_index = 3; + optional uint64 from_vin_index = 4; +} +message Nullifier { + bytes pubkey = 1; // Pkⱼ, §3.1 + bytes r = 2; // Rⱼ, §3.1 + string state = 3; // §3.10 per-member: "completed" | "pending" | "failed" +} +message Inscription { + bytes txid = 1; // internal byte order (§1.7.7) + uint64 height = 2; + uint32 count = 3; + uint32 format = 4; // 0x00 raw | 0x01 half-aggregated (§3.5) + repeated Nullifier nullifiers = 5; // each element carries its own state (§7.5) + string confirmation_state = 6; // reveal-tx confirmation only: "pending" | "completed" + // (never "failed"; not a top-level §3.10 aggregate state) + uint64 tx_index = 7; // reveal-tx index within the block + uint64 vin_index = 8; // reveal-input index within the tx; with height+tx_index + // forms the triple sort/cursor key (§3.6, §7.5) +} + +message NullifierPathRequest { bytes pubkey = 1; } +message NullifierPath { + bytes root = 1; uint64 tip_height = 2; bool present = 3; + bytes leaf = 4; // Rᵢ when present, else empty + uint64 position = 5; // log position p when present + repeated bytes audit_path = 6; // ≤ 64 × 32B RFC-6962 inclusion audit path when present + // (§1.7.6, §3.7); empty when present == false + uint64 tree_size = 7; // log size against which an inclusion proof is stated + bytes tip_block_hash = 8; // 32B, internal order (§1.7.7) + // present == false is an unauthenticated local-index absence answer, NOT an RFC-6962 + // non-inclusion proof; MUST NOT back a credit (§3.7 Path B). +} + +message OutputTemplate { + string recipient = 1; + bytes asset_id = 2; + string amount = 3; + DeliveryCredential delivery = 4; // required for every non-self output; absent on + // self-outputs (§7.5 presence rule). Verification + // is kernel-only (§6.1, §7.5); the API forwards + // the field unchanged and MUST NOT mark it verified. +} +// Closed tagged union matching §7.5 DeliveryCredential. Exactly one arm is set; +// any other shape is malformed_request. The two variants have separate, complete +// check-lists (§7.5): invoice runs the three §4.3 Invoice checks plus byte-exact +// equality of recipient/asset_id/amount with this OutputTemplate; profile runs the +// §4.3 profile chain plus zkcoins.address == output.recipient (amount and asset +// are not compared — a profile is an addressing credential, not a payment +// authorisation). This field is a §1.7.8 between-step-3-and-step-7 wire addition +// (neither circuit nor pinned vector nor digest). +message DeliveryCredential { + oneof body { + Invoice invoice = 1; // type "invoice" — full §1.5 / §4.3 Invoice + Kind0Event profile_event = 2; // type "profile" — full canonical kind-0 event + } +} +message Invoice { + string amount = 1; + string recipient = 2; // zk-address (Bech32m string) + bytes asset_id = 3; + string memo = 4; // empty when absent + bytes pk0 = 5; // 32B x-only + bytes nk_commit = 6; // 32B + bytes ivpk = 7; // 32B + bytes op_pubkey = 8; // 32B x-only + repeated string relays = 9; + bytes addr_sig = 10; // 64B BIP-340 under pk0 + bytes sig = 11; // 64B BIP-340 under op_pubkey +} +message Kind0Event { + bytes id = 1; // 32B event id + bytes pubkey = 2; // 32B author (op_pubkey) + uint64 created_at = 3; + uint32 kind = 4; // MUST be 0 + string tags_json = 5; // canonical JSON array of tags (NIP-01; typically []) + string content = 6; // JSON content carrying the zkcoins object + bytes sig = 7; // 64B Nostr event signature under author +} +message Issuance { + string name = 1; uint32 decimals = 2; uint32 issuance_version = 3; + string amount = 4; + string cap_total = 5; // set iff issuance_version == 2 + bytes terms_salt = 6; // set iff issuance_version == 2 + bytes creator_pubkey = 7; // Pk₀ (32-byte x-only); required both versions +} +message TransitionRequest { + string kind = 1; // "mint" | "send" | "receive" + string subject = 2; // zk-address (Bech32m string) + bytes next_pubkey = 3; + bytes npk_rand = 11; // 32 unmodified CSPRNG bytes per attempt (§2.1 clause 2) + repeated bytes input_coins = 4; + repeated OutputTemplate output_templates = 5; + bytes publisher_pubkey = 6; // empty ⇒ self-publish (case a); set ⇒ case (b) or (c) + string fee_address = 7; // deferred (§3.8.1): MUST be empty in v1 (§7.5 matrix cases (a)/(c)) + repeated bytes fold_coin_ids = 8; + Issuance issuance = 9; + string idempotency_key = 10; // §7.5 Idempotency-Key pass-through + bytes genesis_pubkey = 12; // recipient's Pk₀ (32B x-only); required for a genesis + // receive (kind=="receive", no prior transition); empty + // (absent) otherwise (§7.5) +} + +message JobHandle { string job_id = 1; string status = 2; } +message JobRequest { string job_id = 1; } +message AwaitingSignature { + bytes new_account_state_hash = 1; bytes output_coins_root = 2; + bytes input_nullifiers_root = 3; bytes coin_history_root = 4; + bytes nav_commitment = 5; bytes npk_commit = 6; + bytes proof_data_hash = 7; // §7.5 awaiting_signature shape + bytes txn_pubkey = 8; // Pkᵢ (x-only); MUST equal prev_account_state.current_pubkey + uint64 send_counter = 9; // entry counter i; skᵢ = A/0'/i' (§1.2, §7.5) +} +message JobResult { + bytes new_account_state_hash = 1; bytes output_coins_root = 2; + bytes input_nullifiers_root = 3; repeated bytes output_coin_ids = 4; + bytes publisher_pubkey = 5; // set for every externally published kind (b)/(c); empty on self-publish (§7.5) + bytes attestation = 6; // set only for attest jobs (§5.7 BalanceAttestation bytes) +} +message JobError { string error = 1; string message = 2; } // §7.5 machine_code shape +message Job { + string job_id = 1; string kind = 2; string status = 3; + string phase = 4; // optional non-stable diagnostic [a-z0-9_]{1,64} (§7.5); + // empty when absent / in terminal status; clients dispatch on status only + float progress = 5; + AwaitingSignature awaiting_signature = 6; // set only while status == "awaiting_signature" + JobResult result = 7; // set only once status == "completed" + JobError error = 8; // set only once status ∈ {"failed","cancelled"} +} +message JobEvent { string event = 1; Job job = 2; } // event: "phase"|"complete"|"error" +message SignRequest { string job_id = 1; bytes signature = 2; bytes s2c_nonce = 3; } // signature length MUST be 64, s2c_nonce length MUST be 32 (INVALID_ARGUMENT otherwise) + +message Scope { // §5.1 scope; all_assets=true ⇔ asset_ids "*" + repeated bytes asset_ids = 1; bool all_assets = 2; + uint64 not_before = 3; uint64 not_after = 4; + // INVARIANT: exactly one of all_assets == true (⇔ asset_ids empty) or a non-empty asset_ids + // MUST hold; all_assets == false with empty asset_ids is INVALID_ARGUMENT. + // UNBOUNDED SENTINELS (identical to the §5.1 JSON scope — single pair, no Proto-only zero + // convention): not_before = 0 means no lower bound; not_after = 2⁶³−1 + // (9223372036854775807) means no upper bound. Proto3 scalar default 0 is therefore + // correct for not_before but **MUST NOT** be read as unbounded for not_after — a bare + // not_after = 0 is a closed window ending at the epoch. The API layer normalises omitted + // JSON fields to these sentinels before the RPC (§5.1, §7.5). +} +message PullChallengeRequest { + string subject = 1; Scope requested_scope = 2; + string action = 3; // "" (pull) | "entrust" | "revoke" (§7.7 domains) + // | "attest_balance" | "issue_grant" (§7.5 action-bound + // OwnershipProof domains; scope unused for those two) +} +message Challenge { bytes nonce = 1; uint64 expiry = 2; string domain = 3; } +message PullRequest { + bytes nonce = 1; // consumes the §5.1 challenge (single use) + string subject = 2; // the subject the API layer authenticated + Scope resolved_scope = 3; // the already-intersected scope (§5.1) — the kernel + // trusts the API layer for ACCESS, never widens + bytes chan_bind = 4; // opaque 32B equality token for session binding (§5.1) +} +message RecordRef { + bytes record_id = 1; // opaque 32B id of this Private record + string record_type = 2; // closed: "coinproof" | "self_delivery" — body-type discriminator (§7.5) + string transition_kind = 3; // closed: "mint" | "send" | "receive"; required for self_delivery; + // optional (empty) for coinproof — NOT a body-type tag + bytes blob_id = 4; // H(ciphertext), §4.2.1 + uint64 occurred_at = 5; // first-occurrence-derived; 0 if unknown +} +message PullResult { repeated RecordRef records = 1; string session = 2; uint64 session_expiry = 3; } +message RecordRequest { bytes record_id = 1; string session = 2; bytes chan_bind = 3; } +message RecordBlob { + bytes canonical = 1; // §7.1 CoinProof or SelfDeliveryRecordV1 bytes + string record_type = 2; // closed: "coinproof" | "self_delivery" — discriminates canonical + string transition_kind = 3; // closed: "mint" | "send" | "receive"; required for self_delivery; + // optional (empty) for coinproof +} +message CoinProofRequest { bytes coin_id = 1; string session = 2; bytes chan_bind = 3; } +message CoinProofBlob { bytes canonical = 1; } // the §7.1 canonical CoinProof bundle bytes + +// ownership pull session only — grant sessions are UNAUTHENTICATED/unauthorized (§7.5) +message AccountStateRequest { string session = 1; bytes chan_bind = 2; } +message AccountStateResult { + bytes account_state = 1; // serialize(AccountState), §1.7.4 + bytes state_head = 2; // ash of the spendable head (32B) + bytes head_record_id = 3; // 32B Private-record locator; empty if not indexed + uint64 send_counter = 4; // MUST equal AccountState.send_counter + bytes current_pubkey = 5; // Pkᵢ (32B x-only); MUST equal AccountState.current_pubkey + bytes last_nullifier_pk = 6; // 32B; empty iff no prior state-advancing transition + bytes last_nullifier_r = 7; // 32B; empty iff last_nullifier_pk empty +} + +// session + chan_bind only — subject/scope come from the server-side pull-session state +// (ownership or grant), never from a client-supplied subject field (analogous to CoinProofRequest) +message SubscribeReceiptsRequest { string session = 1; bytes chan_bind = 2; } +message Receipt { + bytes coin_id = 1; bytes asset_id = 2; string amount = 3; + string state = 4; // §3.10 state at emission + uint64 credited_at = 5; +} + +message BlockAnchor { bytes block_hash = 1; uint32 height = 2; } // hash internal order (§1.7.7); height matches the on-chain u32 (§1.7.3) +message PublishRequest { + bytes public_key = 1; bytes r = 2; bytes s = 3; bytes r_prime = 4; + bytes fee_blob_id = 5; // 32B; deferred (§3.8.1): MUST be empty in v1 ⇒ fee-less (§7.6) + BlockAnchor block_anchor = 6; + bytes fee_epk = 7; // 32B x-only; empty iff fee_blob_id empty; fresh per hand-off + bytes fee_blob_locators = 8; // UTF-8 of NIP44Binary(K_tx, "blob-locators", serialize(BlobLocatorSet)); empty iff fee_blob_id empty +} +message PublishResult { + bool accepted = 1; + optional string reason = 2; // present iff accepted == false; closed set (§7.6): + // "invalid_signature" | "invalid_s2c_opening" | "invalid_fee_coinproof" + // | "fee_address_mismatch" | "ocr_mismatch" | "fee_too_low" + // | "unknown_fee_asset" | "policy" | "anchor_stale" + // (proto3 optional: absence ≠ empty string) + optional uint64 batch_eta = 3; // seconds to next inscription; present iff accepted == true + // (proto3 optional: absence ≠ 0) +} +message EntrustRequest { bytes nonce = 1; string subject = 2; bytes bundle = 3; bytes chan_bind = 4; } // bundle = the 161-byte §7.7 serialization +message EntrustResult { bool accepted = 1; } +message RevokeRequest { bytes nonce = 1; string subject = 2; bytes chan_bind = 3; } +message RevokeResult { bool revoked = 1; } // §7.7 fail-closed revocation: irrecoverably erase {ivk, ovk, op, nk, op_secret} +// API layer has already verified the action-bound OwnershipProof (§5.1 / §7.5); kernel trusts +// the caller for ACCESS and consumes the single-use nonce for audit/idempotency of the gate +message AttestRequest { + string subject = 1; bytes asset_id = 2; + bytes nav_ceiling = 3; // 32B nav_root; empty ⇒ node's current size_final + uint64 size_ceiling = 4; // 0 ⇒ derive from size_final + bytes nonce = 5; // consumes the AttestBalanceChallenge (single use) + bytes chan_bind = 6; // opaque 32B equality token (§5.1) +} +message GrantRequest { + string subject = 1; bytes grantee_pk = 2; Scope scope = 3; uint64 expiry = 4; + bytes nonce = 5; // consumes the IssueGrantChallenge (single use) + bytes chan_bind = 6; // opaque 32B equality token (§5.1) +} +message GrantResult { string grant = 1; } diff --git a/rust-toolchain b/rust-toolchain new file mode 100644 index 0000000..7533883 --- /dev/null +++ b/rust-toolchain @@ -0,0 +1,5 @@ +[toolchain] +# Dated pin: an unpinned channel broke CI repo-wide (stricter rustfmt, new lints). +# `-D warnings` plus a moving channel means CI can go red without a code change. +channel = "nightly-2026-06-18" +components = ["llvm-tools", "rustc-dev", "rustfmt", "clippy"] diff --git a/src/attest.rs b/src/attest.rs new file mode 100644 index 0000000..07ec999 --- /dev/null +++ b/src/attest.rs @@ -0,0 +1,190 @@ +//! Balance-attestation REST surface (§7.5 L2893–L2894). +//! +//! | Method | Path | Kernel | +//! |---|---|---| +//! | `POST` | `/v1/attest/balance/challenge` | `OpenPullChallenge` action=`attest_balance` | +//! | `POST` | `/v1/attest/balance` | `AttestBalance` (after OwnershipProof) | +//! +//! OwnershipProof verification is API-local; the kernel receives only the +//! already-authenticated subject plus `nonce` / `chan_bind`. + +use crate::error::ApiError; +use crate::extract::JsonBody; +use crate::hexutil::{decode_hex_exact, encode_hex}; +use crate::kernel::kernel_v1::{AttestRequest, JobHandle, PullChallengeRequest}; +use crate::ownership::{ + attest_request_hash, ceiling_encoding, decode_zk_address, parse_u64_decimal, + verify_ownership_proof, ChallengeDomain, ChallengeEcho, OwnerOnlyProofJson, + ATTEST_BALANCE_CHALLENGE_DOMAIN, +}; +use crate::state::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::json; + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct AttestChallengeBody { + pub subject: String, +} + +#[derive(Debug, Deserialize)] +pub struct AttestBalanceBody { + pub subject: String, + pub asset_id: String, + #[serde(default)] + pub nav_ceiling: Option, + /// §7.1 decimal-string u64 when present. + #[serde(default)] + pub size_ceiling: Option, + pub challenge: ChallengeEcho, + pub ownership_proof: OwnerOnlyProofJson, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /v1/attest/balance/challenge` → OpenPullChallenge(action=attest_balance). +pub async fn post_attest_balance_challenge( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + if body.subject.is_empty() { + return Err(ApiError::malformed("subject is required")); + } + // Validate Bech32m early so the API returns a clear 400 rather than + // relying on the kernel's parse of the same string. + let _ = decode_zk_address(&body.subject)?; + + let challenge = state + .kernel + .open_pull_challenge(PullChallengeRequest { + subject: body.subject, + requested_scope: None, + action: "attest_balance".to_string(), + }) + .await?; + + if challenge.nonce.len() != 32 { + return Err(ApiError::internal(format!( + "kernel Challenge.nonce must be 32 bytes, got {}", + challenge.nonce.len() + ))); + } + // Domain is endpoint-bound: refuse a kernel that returns a foreign tag. + if challenge.domain != ATTEST_BALANCE_CHALLENGE_DOMAIN { + return Err(ApiError::internal(format!( + "kernel Challenge.domain must be {ATTEST_BALANCE_CHALLENGE_DOMAIN:?}, got {:?}", + challenge.domain + ))); + } + + let body = json!({ + "nonce": encode_hex(&challenge.nonce), + "expiry": challenge.expiry.to_string(), + "domain": ATTEST_BALANCE_CHALLENGE_DOMAIN, + }); + Ok((StatusCode::OK, Json(body)).into_response()) +} + +/// `POST /v1/attest/balance` → verify OwnershipProof, then AttestBalance. +/// +/// Verification runs entirely before the kernel call so a bad signature +/// cannot consume the single-use challenge nonce. +pub async fn post_attest_balance( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + // ---- pure validation + OwnershipProof (no kernel) ---- + let nav_ceiling = match &body.nav_ceiling { + None => None, + Some(h) => { + let v = decode_hex_exact(h, 32) + .map_err(|e| ApiError::malformed(format!("nav_ceiling: {e}")))?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&v); + Some(arr) + } + }; + let size_ceiling = match &body.size_ceiling { + None => None, + Some(s) => Some( + parse_u64_decimal(s) + .map_err(|e| ApiError::malformed(format!("size_ceiling: {}", e.body.message)))?, + ), + }; + let ceiling_enc = ceiling_encoding(nav_ceiling.as_ref(), size_ceiling)?; + + let subject_raw = decode_zk_address(&body.subject)?; + let asset_id = { + let v = decode_hex_exact(&body.asset_id, 32) + .map_err(|e| ApiError::malformed(format!("asset_id: {e}")))?; + let mut arr = [0u8; 32]; + arr.copy_from_slice(&v); + arr + }; + + // Server-computed request_hash — never a client-supplied hash field. + let request_hash = attest_request_hash(&subject_raw, &asset_id, &ceiling_enc); + + // GrantProof arm → 401 before any kernel call (tagged union, not 400). + let ownership_proof = body.ownership_proof.require_ownership()?; + + // Domain is the AttestBalance endpoint constant — not taken from body. + let verified = verify_ownership_proof( + ChallengeDomain::AttestBalance, + &body.subject, + &body.challenge, + &ownership_proof, + &request_hash, + state.public_hosts.as_slice(), + )?; + + // ---- only now: kernel (nonce consumption lives here) ---- + let (nav_bytes, size_val) = match (nav_ceiling, size_ceiling) { + (None, None) => (Vec::new(), 0u64), + (Some(nav), Some(size)) => (nav.to_vec(), size), + _ => { + // ceiling_encoding already rejected mixed presence. + return Err(ApiError::internal( + "ceiling pair invariant broken after encoding", + )); + } + }; + + let handle: JobHandle = state + .kernel + .attest_balance(AttestRequest { + subject: verified.subject_bech32, + asset_id: asset_id.to_vec(), + nav_ceiling: nav_bytes, + size_ceiling: size_val, + nonce: verified.nonce.to_vec(), + chan_bind: verified.chan_bind.to_vec(), + }) + .await?; + + // §7.5 L2894: `202 { job_id }` — no status field on this admit response. + // JobHandle.status must still be the admit terminal `"accepted"` (same + // contract as POST /v1/tx); any other value is a kernel protocol fault. + if handle.job_id.is_empty() { + return Err(ApiError::internal( + "kernel JobHandle.job_id is empty on AttestBalance success", + )); + } + if handle.status != "accepted" { + return Err(ApiError::internal(format!( + "kernel JobHandle.status must be \"accepted\" on AttestBalance success, got {:?}", + handle.status + ))); + } + let body = json!({ "job_id": handle.job_id }); + Ok((StatusCode::ACCEPTED, Json(body)).into_response()) +} diff --git a/src/blossom/auth.rs b/src/blossom/auth.rs new file mode 100644 index 0000000..faee5a4 --- /dev/null +++ b/src/blossom/auth.rs @@ -0,0 +1,657 @@ +//! Kind-`24242` Blossom authorization events (§7.4). +//! +//! Pure verification: every check takes an injected `now_unix` so the time +//! window is unit-testable (same discipline as challenge-echo expiry — the +//! verifier never reads the system clock itself). +//! +//! Wire form: `Authorization: Nostr `. +//! +//! Data permanence (Requirement 12): only **upload** authorization is +//! defined. There is no `t=delete` action and no DELETE route. + +use crate::blossom::base64; +use crate::error::ApiError; +use crate::hexutil::decode_hex_exact; +use crate::ownership::verify_bip340; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +/// Recommended replay window from §7.4 (seconds). Fixed server-side bound. +pub const REPLAY_WINDOW_SECS: u64 = 300; + +/// Clock-skew allowance: `created_at ≤ now + CLOCK_SKEW_SECS`. +pub const CLOCK_SKEW_SECS: u64 = 60; + +/// Nostr event kind for Blossom upload authorization. +pub const BLOSSOM_AUTH_KIND: u64 = 24242; + +/// Action tag value for PUT/POST upload. +pub const TAG_T_UPLOAD: &str = "upload"; + +/// Decoded and cryptographically verified kind-24242 event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedAuthEvent { + /// `op` x-only public key (32 bytes) that signed the event. + pub op_pubkey: [u8; 32], + /// `t` tag: always `"upload"` for v1 (data permanence — no delete). + pub action: AuthAction, + /// `x` tag: body hash of the upload. + pub x_tag: [u8; 32], + /// Parsed `expiration` tag (unix seconds). + pub expiration: u64, + /// Event `created_at`. + pub created_at: u64, + /// Nostr event id (SHA-256 of the canonical serialization). + pub event_id: [u8; 32], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthAction { + Upload, +} + +impl AuthAction { + pub const fn as_str(self) -> &'static str { + match self { + AuthAction::Upload => TAG_T_UPLOAD, + } + } +} + +/// Expected action for the HTTP method under check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequiredAction { + Upload, +} + +impl RequiredAction { + pub const fn as_action(self) -> AuthAction { + match self { + RequiredAction::Upload => AuthAction::Upload, + } + } +} + +#[derive(Debug, Deserialize)] +struct WireEvent { + id: String, + pubkey: String, + created_at: u64, + kind: u64, + tags: Vec>, + #[serde(default)] + content: String, + sig: String, +} + +/// Parse `Authorization: Nostr ` and fully verify a kind-24242 event. +/// +/// # Arguments +/// +/// * `authorization_header` — full `Authorization` header value +/// * `required` — method-selected action (upload only under data permanence) +/// * `x_expected` — `H(actual body)`. The `x` tag is checked **against this +/// value**, not against any header claim — that is the whole authorization hinge. +/// * `now_unix` — injected clock (seconds since epoch) +/// +/// # Status codes (§7.4) +/// +/// Signature / kind / `t` / `x` / time-window failures → `401 unauthorized`. +/// Malformed header framing (not `Nostr …`) → `401` as well (capability +/// missing/invalid). The caller maps `op`-key ACL failures to `403`. +pub fn verify_blossom_auth( + authorization_header: &str, + required: RequiredAction, + x_expected: &[u8; 32], + now_unix: u64, +) -> Result { + let b64 = parse_nostr_authorization(authorization_header)?; + let raw = base64::decode(b64).map_err(|e| { + ApiError::unauthorized(format!( + "Authorization Nostr payload is not valid base64: {e}" + )) + })?; + let event: WireEvent = serde_json::from_slice(&raw).map_err(|e| { + ApiError::unauthorized(format!( + "Authorization Nostr payload is not a JSON event: {e}" + )) + })?; + + // kind + if event.kind != BLOSSOM_AUTH_KIND { + return Err(ApiError::unauthorized(format!( + "auth event kind must be {BLOSSOM_AUTH_KIND}, got {}", + event.kind + ))); + } + + // content must be empty (§7.4) + if !event.content.is_empty() { + return Err(ApiError::unauthorized("auth event content must be empty")); + } + + let op_pubkey = parse_hex32_lower_or_upper(&event.pubkey, "auth event pubkey")?; + let sig = parse_hex64_field(&event.sig, "auth event sig")?; + let claimed_id = parse_hex32_lower_or_upper(&event.id, "auth event id")?; + + // Recompute event id from the canonical serialization and require match. + let computed_id = compute_event_id( + &event.pubkey, + event.created_at, + event.kind, + &event.tags, + &event.content, + )?; + if computed_id != claimed_id { + return Err(ApiError::unauthorized( + "auth event id does not match canonical serialization", + )); + } + + // BIP-340 over the event id under the op pubkey. + verify_bip340(&op_pubkey, &sig, &computed_id) + .map_err(|_| ApiError::unauthorized("auth event signature invalid"))?; + + // Tags: t, x, expiration — each required exactly once for v1. + let action = require_t_tag(&event.tags)?; + if action != required.as_action() { + return Err(ApiError::unauthorized(format!( + "auth event t tag is {:?}, expected {:?} for this method", + action.as_str(), + required.as_action().as_str() + ))); + } + + let x_tag = require_x_tag(&event.tags)?; + if x_tag != *x_expected { + return Err(ApiError::unauthorized( + "auth event x tag does not match the actual body hash / target blob", + )); + } + + let expiration = require_expiration_tag(&event.tags)?; + + // Time window — pure over injected now. + check_time_window(event.created_at, expiration, now_unix)?; + + Ok(VerifiedAuthEvent { + op_pubkey, + action, + x_tag, + expiration, + created_at: event.created_at, + event_id: computed_id, + }) +} + +/// `created_at ≤ now + 60` and `created_at ≥ now − replay_window` and +/// `expiration ≥ now`. Pure: takes `now_unix` as an argument. +pub fn check_time_window(created_at: u64, expiration: u64, now_unix: u64) -> Result<(), ApiError> { + if expiration < now_unix { + return Err(ApiError::unauthorized(format!( + "auth event expiration {expiration} is in the past (now {now_unix})" + ))); + } + // created_at ≤ now + 60 (clock skew) + let max_future = now_unix.saturating_add(CLOCK_SKEW_SECS); + if created_at > max_future { + return Err(ApiError::unauthorized(format!( + "auth event created_at {created_at} is more than {CLOCK_SKEW_SECS}s ahead of now {now_unix}" + ))); + } + // created_at ≥ now − replay_window + let min_created = now_unix.saturating_sub(REPLAY_WINDOW_SECS); + if created_at < min_created { + return Err(ApiError::unauthorized(format!( + "auth event created_at {created_at} is older than replay window \ + ({REPLAY_WINDOW_SECS}s) relative to now {now_unix}" + ))); + } + Ok(()) +} + +fn parse_nostr_authorization(header: &str) -> Result<&str, ApiError> { + let header = header.trim(); + const PREFIX: &str = "Nostr "; + if let Some(rest) = header.strip_prefix(PREFIX) { + if rest.is_empty() { + return Err(ApiError::unauthorized( + "Authorization Nostr payload is empty", + )); + } + return Ok(rest.trim()); + } + // Also accept case-sensitive "Nostr" only per BUD-01 convention; anything + // else is a missing/invalid capability. + Err(ApiError::unauthorized( + "Authorization must be \"Nostr \"", + )) +} + +fn require_t_tag(tags: &[Vec]) -> Result { + let mut found: Option = None; + for tag in tags { + if tag.first().map(String::as_str) != Some("t") { + continue; + } + let value = tag + .get(1) + .map(String::as_str) + .ok_or_else(|| ApiError::unauthorized("auth event t tag is missing its value"))?; + let action = match value { + TAG_T_UPLOAD => AuthAction::Upload, + other => { + return Err(ApiError::unauthorized(format!( + "auth event t tag must be \"upload\", got {other:?}" + ))); + } + }; + if found.is_some() { + return Err(ApiError::unauthorized( + "auth event must not carry multiple t tags", + )); + } + found = Some(action); + } + found.ok_or_else(|| ApiError::unauthorized("auth event is missing the t tag")) +} + +fn require_x_tag(tags: &[Vec]) -> Result<[u8; 32], ApiError> { + let mut found: Option<[u8; 32]> = None; + for tag in tags { + if tag.first().map(String::as_str) != Some("x") { + continue; + } + let value = tag + .get(1) + .map(String::as_str) + .ok_or_else(|| ApiError::unauthorized("auth event x tag is missing its value"))?; + // x is lowercase-hex SHA-256 of body / blob_id. + if value.len() != 64 + || !value + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + { + return Err(ApiError::unauthorized( + "auth event x tag must be 64 lowercase hex characters", + )); + } + let bytes = decode_hex_exact(value, 32) + .map_err(|e| ApiError::unauthorized(format!("auth event x tag: {e}")))?; + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + if found.is_some() { + return Err(ApiError::unauthorized( + "auth event must not carry multiple x tags", + )); + } + found = Some(out); + } + found.ok_or_else(|| ApiError::unauthorized("auth event is missing the x tag")) +} + +fn require_expiration_tag(tags: &[Vec]) -> Result { + let mut found: Option = None; + for tag in tags { + if tag.first().map(String::as_str) != Some("expiration") { + continue; + } + let value = tag.get(1).map(String::as_str).ok_or_else(|| { + ApiError::unauthorized("auth event expiration tag is missing its value") + })?; + let exp = parse_decimal_u64(value) + .map_err(|m| ApiError::unauthorized(format!("auth event expiration: {m}")))?; + if found.is_some() { + return Err(ApiError::unauthorized( + "auth event must not carry multiple expiration tags", + )); + } + found = Some(exp); + } + found.ok_or_else(|| ApiError::unauthorized("auth event is missing the expiration tag")) +} + +fn parse_decimal_u64(s: &str) -> Result { + if s.is_empty() { + return Err("empty".into()); + } + if s == "0" { + return Ok(0); + } + if s.as_bytes()[0] == b'0' { + return Err("leading zeros are not allowed".into()); + } + if !s.bytes().all(|b| b.is_ascii_digit()) { + return Err("must be decimal digits only".into()); + } + s.parse::().map_err(|_| "out of u64 range".to_string()) +} + +/// Accept lowercase or uppercase hex for Nostr `pubkey`/`id` fields (NIP-01 +/// commonly uses lowercase; reject wrong width still). +fn parse_hex32_lower_or_upper(s: &str, field: &str) -> Result<[u8; 32], ApiError> { + let v = decode_hex_exact(s, 32).map_err(|e| ApiError::unauthorized(format!("{field}: {e}")))?; + let mut out = [0u8; 32]; + out.copy_from_slice(&v); + Ok(out) +} + +fn parse_hex64_field(s: &str, field: &str) -> Result<[u8; 64], ApiError> { + let v = decode_hex_exact(s, 64).map_err(|e| ApiError::unauthorized(format!("{field}: {e}")))?; + let mut out = [0u8; 64]; + out.copy_from_slice(&v); + Ok(out) +} + +/// NIP-01 event id: `SHA-256(JSON-array [0, pubkey, created_at, kind, tags, content])`. +/// +/// Uses the **wire** `pubkey` string (as presented) and a compact JSON array +/// with no insignificant whitespace. Tags are serialised as JSON arrays of +/// strings in order. +fn compute_event_id( + pubkey_hex: &str, + created_at: u64, + kind: u64, + tags: &[Vec], + content: &str, +) -> Result<[u8; 32], ApiError> { + // Build the canonical array via serde_json so string escaping matches + // the JSON the client signed. + let tags_value: Vec = tags + .iter() + .map(|t| Value::Array(t.iter().cloned().map(Value::String).collect())) + .collect(); + let arr = Value::Array(vec![ + Value::Number(0.into()), + Value::String(pubkey_hex.to_string()), + Value::Number(created_at.into()), + Value::Number(kind.into()), + Value::Array(tags_value), + Value::String(content.to_string()), + ]); + let serialized = serde_json::to_vec(&arr) + .map_err(|e| ApiError::internal(format!("auth event id serialization failed: {e}")))?; + Ok(Sha256::digest(&serialized).into()) +} + +/// Build a signed kind-24242 event (tests / helpers). Returns the base64 +/// payload for the `Authorization: Nostr …` header. +#[cfg(test)] +pub fn sign_auth_event_base64( + sk: &bitcoin::secp256k1::SecretKey, + pubkey: &[u8; 32], + action: AuthAction, + x_tag: &[u8; 32], + created_at: u64, + expiration: u64, +) -> String { + use crate::hexutil::encode_hex; + use bitcoin::secp256k1::{Keypair, Message, Secp256k1}; + + let pubkey_hex = encode_hex(pubkey); + let tags = vec![ + vec!["t".to_string(), action.as_str().to_string()], + vec!["x".to_string(), encode_hex(x_tag)], + vec!["expiration".to_string(), expiration.to_string()], + ]; + let content = String::new(); + let id = compute_event_id(&pubkey_hex, created_at, BLOSSOM_AUTH_KIND, &tags, &content) + .expect("event id"); + let secp = Secp256k1::new(); + let kp = Keypair::from_secret_key(&secp, sk); + let msg = Message::from_digest_slice(&id).expect("32-byte digest"); + let sig = secp.sign_schnorr_no_aux_rand(&msg, &kp); + let mut sig_bytes = [0u8; 64]; + sig_bytes.copy_from_slice(sig.as_ref()); + + let event = serde_json::json!({ + "id": encode_hex(&id), + "pubkey": pubkey_hex, + "created_at": created_at, + "kind": BLOSSOM_AUTH_KIND, + "tags": tags, + "content": content, + "sig": encode_hex(&sig_bytes), + }); + base64::encode(event.to_string().as_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + + fn sample_sk_pk() -> (SecretKey, [u8; 32]) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x7au8; 32]).expect("secret"); + let kp = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = kp.x_only_public_key(); + (sk, xonly.serialize()) + } + + #[test] + fn valid_upload_event_verifies() { + let (sk, pk) = sample_sk_pk(); + let x = [0xabu8; 32]; + let now = 1_700_000_000u64; + let b64 = sign_auth_event_base64(&sk, &pk, AuthAction::Upload, &x, now, now + 60); + let header = format!("Nostr {b64}"); + let v = verify_blossom_auth(&header, RequiredAction::Upload, &x, now).expect("ok"); + assert_eq!(v.op_pubkey, pk); + assert_eq!(v.action, AuthAction::Upload); + assert_eq!(v.x_tag, x); + } + + #[test] + fn delete_t_tag_is_401() { + // Data permanence: t=delete is not a valid auth action. + let (sk, pk) = sample_sk_pk(); + let x = [0x11u8; 32]; + let now = 1_700_000_000u64; + // Build a valid-looking event with t=delete by signing a custom tag set. + use crate::hexutil::encode_hex; + use bitcoin::secp256k1::{Keypair, Message, Secp256k1}; + let pubkey_hex = encode_hex(&pk); + let tags = vec![ + vec!["t".to_string(), "delete".to_string()], + vec!["x".to_string(), encode_hex(&x)], + vec!["expiration".to_string(), (now + 60).to_string()], + ]; + let content = String::new(); + let id = compute_event_id(&pubkey_hex, now, BLOSSOM_AUTH_KIND, &tags, &content).unwrap(); + let secp = Secp256k1::new(); + let kp = Keypair::from_secret_key(&secp, &sk); + let msg = Message::from_digest_slice(&id).unwrap(); + let sig = secp.sign_schnorr_no_aux_rand(&msg, &kp); + let mut sig_bytes = [0u8; 64]; + sig_bytes.copy_from_slice(sig.as_ref()); + let event = serde_json::json!({ + "id": encode_hex(&id), + "pubkey": pubkey_hex, + "created_at": now, + "kind": BLOSSOM_AUTH_KIND, + "tags": tags, + "content": content, + "sig": encode_hex(&sig_bytes), + }); + let b64 = base64::encode(event.to_string().as_bytes()); + let err = verify_blossom_auth(&format!("Nostr {b64}"), RequiredAction::Upload, &x, now) + .expect_err("delete t must fail"); + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("t tag") || err.body.message.contains("upload"), + "cause must name t tag: {}", + err.body.message + ); + } + + #[test] + fn x_tag_must_match_actual_body_hash() { + let (sk, pk) = sample_sk_pk(); + let signed_x = [0x22u8; 32]; + let actual_x = [0x33u8; 32]; + let now = 1_700_000_000u64; + let b64 = sign_auth_event_base64(&sk, &pk, AuthAction::Upload, &signed_x, now, now + 60); + let err = verify_blossom_auth( + &format!("Nostr {b64}"), + RequiredAction::Upload, + &actual_x, + now, + ) + .expect_err("x mismatch"); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("x tag"), + "cause must name x tag: {}", + err.body.message + ); + } + + #[test] + fn expired_event_is_401() { + let (sk, pk) = sample_sk_pk(); + let x = [0x44u8; 32]; + let now = 1_700_000_100u64; + let b64 = sign_auth_event_base64( + &sk, + &pk, + AuthAction::Upload, + &x, + now - 10, + now - 1, // expiration in the past + ); + let err = verify_blossom_auth(&format!("Nostr {b64}"), RequiredAction::Upload, &x, now) + .expect_err("expired"); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("expiration"), + "cause must name expiration: {}", + err.body.message + ); + } + + #[test] + fn created_at_too_far_future_is_401() { + let (sk, pk) = sample_sk_pk(); + let x = [0x55u8; 32]; + let now = 1_700_000_000u64; + let created = now + CLOCK_SKEW_SECS + 1; + let b64 = sign_auth_event_base64(&sk, &pk, AuthAction::Upload, &x, created, created + 60); + let err = verify_blossom_auth(&format!("Nostr {b64}"), RequiredAction::Upload, &x, now) + .expect_err("future"); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("created_at"), + "cause must name created_at: {}", + err.body.message + ); + } + + #[test] + fn created_at_older_than_replay_window_is_401() { + let (sk, pk) = sample_sk_pk(); + let x = [0x66u8; 32]; + let now = 1_700_000_000u64; + let created = now - REPLAY_WINDOW_SECS - 1; + let b64 = sign_auth_event_base64( + &sk, + &pk, + AuthAction::Upload, + &x, + created, + now + 60, // expiration still valid + ); + let err = verify_blossom_auth(&format!("Nostr {b64}"), RequiredAction::Upload, &x, now) + .expect_err("replay window"); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("replay window"), + "cause must name replay window: {}", + err.body.message + ); + } + + #[test] + fn bad_signature_is_401() { + let (sk, pk) = sample_sk_pk(); + let x = [0x77u8; 32]; + let now = 1_700_000_000u64; + let b64 = sign_auth_event_base64(&sk, &pk, AuthAction::Upload, &x, now, now + 60); + // Flip one base64 character in the payload if possible; simpler: decode, + // tweak sig, re-encode. + let raw = base64::decode(&b64).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&raw).unwrap(); + // Corrupt the last hex nibble of sig. + let sig = v["sig"].as_str().unwrap().to_string(); + let mut chars: Vec = sig.chars().collect(); + let last = chars.len() - 1; + chars[last] = if chars[last] == '0' { '1' } else { '0' }; + v["sig"] = serde_json::Value::String(chars.into_iter().collect()); + let bad = base64::encode(v.to_string().as_bytes()); + let err = verify_blossom_auth(&format!("Nostr {bad}"), RequiredAction::Upload, &x, now) + .expect_err("bad sig"); + // Either id mismatch (if we broke something else) or signature invalid. + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn time_window_is_pure_over_injected_now() { + // Direct unit of the pure helper — no system clock. + // `now` must be large enough that `now − REPLAY_WINDOW_SECS − 1` is a + // real u64 value (small toy clocks like 150 under-flow the "too old" + // case and never exercise the named branch). + let now = 1_700_000_000u64; + + check_time_window(now - 10, now + 60, now).expect("in window"); + + let err = check_time_window(now - 10, now - 1, now).expect_err("expired"); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("expiration"), + "cause must name expiration: {}", + err.body.message + ); + + let err = check_time_window(now + CLOCK_SKEW_SECS + 1, now + 999, now) + .expect_err("created_at too far future"); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("created_at"), + "cause must name created_at: {}", + err.body.message + ); + + let err = check_time_window(now - REPLAY_WINDOW_SECS - 1, now + 999, now) + .expect_err("created_at older than replay window"); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("replay window"), + "cause must name replay window: {}", + err.body.message + ); + } + + #[test] + fn time_window_saturates_when_now_smaller_than_replay_window() { + // Production uses saturating_sub: with now < REPLAY_WINDOW_SECS the + // lower bound is 0, not a panic. created_at = 0 is therefore in-window + // when expiration is still in the future. + let now = 10u64; + assert!( + now < REPLAY_WINDOW_SECS, + "precondition: now under the window" + ); + check_time_window(0, now + 60, now).expect("saturates to min_created = 0"); + // Even created_at = 0 is accepted; there is no "too old" case when + // now < REPLAY_WINDOW_SECS (the window reaches the epoch). + let err = check_time_window(now + CLOCK_SKEW_SECS + 1, now + 999, now) + .expect_err("future still rejected under small now"); + assert!( + err.body.message.contains("created_at"), + "cause must name created_at: {}", + err.body.message + ); + } +} diff --git a/src/blossom/base64.rs b/src/blossom/base64.rs new file mode 100644 index 0000000..0d90a77 --- /dev/null +++ b/src/blossom/base64.rs @@ -0,0 +1,191 @@ +//! Standard Base64 (RFC 4648 §4) **decoder** for Nostr `Authorization` events. +//! +//! Wire form per §7.4 / BUD-01: `Authorization: Nostr `. +//! That is the **standard** alphabet (`A–Z a–z 0–9 + /`) with `=` padding — +//! not base64url (`-` `_`, no pad) used by NIP44Binary in the node tree. +//! +//! Decode-only in production: the server never re-encodes the auth event. + +/// Decode standard Base64 (with `=` padding). Rejects URL-safe alphabet and +/// non-alphabet characters. +pub fn decode(input: &str) -> Result, Base64Error> { + if input.is_empty() { + return Ok(Vec::new()); + } + if !input.len().is_multiple_of(4) { + return Err(Base64Error::Length); + } + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(input.len() / 4 * 3); + let mut i = 0; + while i < bytes.len() { + let is_last = i + 4 >= bytes.len(); + let b0 = val(bytes[i])?; + let b1 = val(bytes[i + 1])?; + let (b2, pad2) = if bytes[i + 2] == b'=' { + if !is_last || bytes[i + 3] != b'=' { + return Err(Base64Error::Padding); + } + (0, true) + } else { + (val(bytes[i + 2])?, false) + }; + let (b3, pad3) = if bytes[i + 3] == b'=' { + if !is_last { + return Err(Base64Error::Padding); + } + (0, true) + } else { + if pad2 { + return Err(Base64Error::Padding); + } + (val(bytes[i + 3])?, false) + }; + let n = (b0 << 18) | (b1 << 12) | (b2 << 6) | b3; + out.push((n >> 16) as u8); + if !pad2 { + out.push((n >> 8) as u8); + } + if !pad3 { + out.push(n as u8); + } + i += 4; + } + Ok(out) +} + +fn val(b: u8) -> Result { + match b { + b'A'..=b'Z' => Ok((b - b'A') as u32), + b'a'..=b'z' => Ok((b - b'a' + 26) as u32), + b'0'..=b'9' => Ok((b - b'0' + 52) as u32), + b'+' => Ok(62), + b'/' => Ok(63), + _ => Err(Base64Error::Char(b)), + } +} + +/// Distinct failure modes of standard Base64 decoding. +/// +/// The enum name already carries the domain; variants name the cause only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Base64Error { + /// Byte outside the standard alphabet (`A–Z a–z 0–9 + /` and `=` only in pad positions). + Char(u8), + /// Input length is not a multiple of 4 (standard padded form). + Length, + /// `=` in a non-terminal position, missing trailing pad, or pad before a non-pad. + Padding, +} + +impl std::fmt::Display for Base64Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Base64Error::Char(b) => write!(f, "invalid base64 character 0x{b:02x}"), + Base64Error::Length => write!(f, "invalid base64 length"), + Base64Error::Padding => write!(f, "invalid base64 padding"), + } + } +} + +impl std::error::Error for Base64Error {} + +/// Encode raw bytes as standard Base64 with `=` padding. +/// +/// Test/helper only — production auth path is decode-only. +#[cfg(test)] +pub fn encode(input: &[u8]) -> String { + const ENCODE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + let mut i = 0; + while i + 3 <= input.len() { + let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8) | (input[i + 2] as u32); + out.push(ENCODE[((n >> 18) & 0x3f) as usize] as char); + out.push(ENCODE[((n >> 12) & 0x3f) as usize] as char); + out.push(ENCODE[((n >> 6) & 0x3f) as usize] as char); + out.push(ENCODE[(n & 0x3f) as usize] as char); + i += 3; + } + match input.len() - i { + 0 => {} + 1 => { + let n = (input[i] as u32) << 16; + out.push(ENCODE[((n >> 18) & 0x3f) as usize] as char); + out.push(ENCODE[((n >> 12) & 0x3f) as usize] as char); + out.push('='); + out.push('='); + } + 2 => { + let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8); + out.push(ENCODE[((n >> 18) & 0x3f) as usize] as char); + out.push(ENCODE[((n >> 12) & 0x3f) as usize] as char); + out.push(ENCODE[((n >> 6) & 0x3f) as usize] as char); + out.push('='); + } + _ => unreachable!(), + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decode_empty() { + assert_eq!(decode("").unwrap(), b""); + } + + #[test] + fn decode_rfc4648_vectors() { + // RFC 4648 §10 — known standard-Base64 encodings (no encoder in prod path). + assert_eq!(decode("Zg==").unwrap(), b"f"); + assert_eq!(decode("Zm8=").unwrap(), b"fo"); + assert_eq!(decode("Zm9v").unwrap(), b"foo"); + assert_eq!(decode("Zm9vYg==").unwrap(), b"foob"); + assert_eq!(decode("Zm9vYmE=").unwrap(), b"fooba"); + assert_eq!(decode("Zm9vYmFy").unwrap(), b"foobar"); + } + + #[test] + fn encode_round_trip_via_test_helper() { + // Test-only encoder: produce and re-decode. + assert_eq!(encode(b""), ""); + assert_eq!(encode(b"f"), "Zg=="); + assert_eq!(encode(b"fo"), "Zm8="); + assert_eq!(encode(b"foo"), "Zm9v"); + assert_eq!(encode(b"foob"), "Zm9vYg=="); + assert_eq!(encode(b"fooba"), "Zm9vYmE="); + assert_eq!(encode(b"foobar"), "Zm9vYmFy"); + for plain in [ + b"" as &[u8], + b"f", + b"fo", + b"foo", + b"foob", + b"fooba", + b"foobar", + ] { + assert_eq!(decode(&encode(plain)).unwrap(), plain); + } + } + + #[test] + fn rejects_url_safe_alphabet() { + // base64url would use `-`/`_`; standard decode must refuse them. + let err = decode("Zm9v-g==").expect_err("url-safe char"); + assert!(matches!(err, Base64Error::Char(b'-'))); + } + + #[test] + fn rejects_bad_length() { + let err = decode("Zm9").expect_err("len % 4 != 0"); + assert_eq!(err, Base64Error::Length); + } + + #[test] + fn rejects_bad_padding() { + let err = decode("Zg=A").expect_err("pad then non-pad"); + assert_eq!(err, Base64Error::Padding); + } +} diff --git a/src/blossom/mod.rs b/src/blossom/mod.rs new file mode 100644 index 0000000..d7014f4 --- /dev/null +++ b/src/blossom/mod.rs @@ -0,0 +1,236 @@ +//! §7.4 Blossom blob store — API-local, content-addressed, no kernel RPC. +//! +//! Three routes, one filesystem store. Discovery keys +//! `blossom_get` / `blossom_head` / `blossom_upload` are advertised **if and +//! only if** `ZKCOINS_BLOSSOM_STORE` is configured. +//! +//! ## Data permanence (Requirement 12) +//! +//! The store is **append-only**. There is **no** `DELETE` route, no retention +//! hold, and no server-side prune of received blobs. Successful upload +//! responses are exactly `{ "blob_id": }` — there is no `receipt` +//! field (`ReplicaReceiptV1` / §4.6 dual-commit replication was removed from +//! the spec). Upload remains ACL-gated (paired accounts + configured peers). + +mod auth; +mod base64; +mod store; + +#[cfg(test)] +pub use auth::sign_auth_event_base64; +pub use auth::{ + verify_blossom_auth, AuthAction, RequiredAction, VerifiedAuthEvent, CLOCK_SKEW_SECS, + REPLAY_WINDOW_SECS, +}; +pub use store::{blob_id_of, BlobStore}; + +use crate::error::ApiError; +use crate::extract::LimitedBytes; +use crate::hexutil::encode_hex; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; +use std::collections::BTreeSet; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Runtime handle for the Blossom surface (store + ACL + size limit). +#[derive(Clone)] +pub struct BlossomState { + pub store: Arc, + pub max_blob_bytes: u64, + /// `op` keys allowed to upload (paired accounts + replication peers). + pub allowed_upload_ops: Arc>, +} + +impl BlossomState { + pub fn from_config(cfg: &crate::config::BlossomConfig) -> Result { + let store = BlobStore::open(cfg.store_root.clone())?; + Ok(Self { + store: Arc::new(store), + max_blob_bytes: cfg.max_blob_bytes, + allowed_upload_ops: Arc::new(cfg.allowed_upload_ops.clone()), + }) + } +} + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +/// Successful upload body. No `receipt` field — data permanence / no §4.6 +/// dual-commit; serde never emits the key (honest omission, not `null`). +#[derive(Debug, Serialize)] +struct UploadResponse { + blob_id: String, +} + +// --------------------------------------------------------------------------- +// Blocking store helpers (keep reactor threads free of sync fsync/read) +// --------------------------------------------------------------------------- + +async fn store_read(store: Arc, id: [u8; 32]) -> Result>, ApiError> { + tokio::task::spawn_blocking(move || store.read(&id)) + .await + .map_err(|e| ApiError::internal(format!("blossom store read join: {e}")))? +} + +async fn store_size(store: Arc, id: [u8; 32]) -> Result, ApiError> { + tokio::task::spawn_blocking(move || store.size(&id)) + .await + .map_err(|e| ApiError::internal(format!("blossom store size join: {e}")))? +} + +async fn store_put( + store: Arc, + body: axum::body::Bytes, + uploader: [u8; 32], +) -> Result<[u8; 32], ApiError> { + tokio::task::spawn_blocking(move || store.put(&body, &uploader)) + .await + .map_err(|e| ApiError::internal(format!("blossom store put join: {e}")))? +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `GET /blossom/` — unauthenticated raw bytes. +pub async fn get_blob( + State(state): State, + Path(sha256): Path, +) -> Result { + let blossom = require_blossom(&state)?; + let id = BlobStore::parse_blob_id(&sha256)?; + let bytes = store_read(Arc::clone(&blossom.store), id) + .await? + .ok_or_else(|| ApiError::not_found(format!("blob {sha256} not found")))?; + let mut res = Response::new(axum::body::Body::from(bytes)); + *res.status_mut() = StatusCode::OK; + res.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + Ok(res) +} + +/// `HEAD /blossom/` — existence / size probe. +pub async fn head_blob( + State(state): State, + Path(sha256): Path, +) -> Result { + let blossom = require_blossom(&state)?; + let id = BlobStore::parse_blob_id(&sha256)?; + let size = store_size(Arc::clone(&blossom.store), id) + .await? + .ok_or_else(|| ApiError::not_found(format!("blob {sha256} not found")))?; + let mut res = Response::new(axum::body::Body::empty()); + *res.status_mut() = StatusCode::OK; + res.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + res.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&size.to_string()) + .map_err(|_| ApiError::internal("content-length header value is not valid"))?, + ); + Ok(res) +} + +/// `PUT` / `POST /blossom/upload` — raw body, kind-24242 auth. +pub async fn upload_blob( + State(state): State, + headers: HeaderMap, + LimitedBytes(body): LimitedBytes, +) -> Result { + let blossom = require_blossom(&state)?; + + // Content-Type is mandatory application/octet-stream. + require_octet_stream(&headers)?; + + // Body size — advertised limit, no clamping. The route-level body limit is + // set to the same max so axum buffering rejects far-oversized bodies; both + // paths map to §7.5 `payload_too_large` (handler check + LimitedBytes). + let max = blossom.max_blob_bytes; + let body_len = body.len() as u64; + if body_len > max { + return Err(ApiError::payload_too_large(format!( + "upload body is {body_len} bytes; advertised limit is {max} bytes" + ))); + } + + // Server computes blob_id = H(body); never trusts a client claim. + let body_hash = blob_id_of(&body); + + let auth_header = headers + .get(header::AUTHORIZATION) + .ok_or_else(|| ApiError::unauthorized("missing Authorization header for blossom upload"))? + .to_str() + .map_err(|_| ApiError::unauthorized("Authorization header is not valid UTF-8"))?; + + let now = unix_now(); + let verified = verify_blossom_auth(auth_header, RequiredAction::Upload, &body_hash, now)?; + + // ACL: op must be a paired account or configured replication peer. + if !blossom.allowed_upload_ops.contains(&verified.op_pubkey) { + return Err(ApiError::scope_exceeded( + "upload op key is neither a paired account nor a configured replication peer", + )); + } + + let id = store_put(Arc::clone(&blossom.store), body, verified.op_pubkey).await?; + debug_assert_eq!(id, body_hash); + + Ok(( + StatusCode::OK, + Json(UploadResponse { + blob_id: encode_hex(&id), + }), + ) + .into_response()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn require_blossom(state: &AppState) -> Result<&BlossomState, ApiError> { + state.blossom.as_ref().ok_or_else(|| { + // Routes are only mounted when configured; this branch is a + // programming error if reached on a live path. + ApiError::internal("blossom surface reached without configuration") + }) +} + +fn require_octet_stream(headers: &HeaderMap) -> Result<(), ApiError> { + // §7.4 non-conforming upload form (JSON / multipart / missing CT) → + // `400 malformed_request` (closed §7.5 set; no `unsupported_media_type`). + let Some(ct) = headers.get(header::CONTENT_TYPE) else { + return Err(ApiError::malformed( + "Content-Type application/octet-stream is required for blossom upload", + )); + }; + let ct = ct + .to_str() + .map_err(|_| ApiError::malformed("Content-Type is not valid UTF-8"))?; + // Exact media type; parameters (e.g. charset) are not a conforming form. + let media = ct.split(';').next().unwrap_or(ct).trim(); + if media != "application/octet-stream" { + return Err(ApiError::malformed(format!( + "Content-Type must be application/octet-stream, got {media:?} \ + (multipart and JSON are not a conforming v1 upload form)" + ))); + } + Ok(()) +} + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before UNIX_EPOCH") + .as_secs() +} diff --git a/src/blossom/store.rs b/src/blossom/store.rs new file mode 100644 index 0000000..07b8eca --- /dev/null +++ b/src/blossom/store.rs @@ -0,0 +1,654 @@ +//! Content-addressed blob store on the local filesystem (§7.4 / §4.2.1). +//! +//! ## Data permanence (Requirement 12) +//! +//! The store is **append-only**. Received bytes are never deleted by this +//! process: there is no public DELETE, no retention sweep, no orphan prune on +//! open, and no post-install rollback of an installed content-addressed object. +//! Temp files used during a single `put` may be cleaned up (they are not +//! durable names). +//! +//! ## Address = content +//! +//! `blob_id = SHA-256(body)` (lowercase hex). The on-disk filename is that +//! hex string and **nothing else**. Path parameters are validated as exactly +//! 64 lowercase hex characters *before* they become a path component, so +//! traversal (`..`, separators, uppercase, Unicode tricks) is structurally +//! impossible — not filtered after the fact. +//! +//! ## Atomic write (no-replace) +//! +//! Upload writes blob and uploader-note to unique temp files in the same +//! directory, then installs each final name with **hard-link create-new** +//! semantics (`hard_link` fails with `AlreadyExists` if the target is +//! present). That closes the TOCTOU between `is_file()` and `rename()`, and +//! never replaces an existing content-addressed object or note. +//! +//! ## Blob + note pair +//! +//! A durable object is the pair `(blob, note)`. Install order is blob then +//! note. A crash between the two can leave a blob without a note — +//! **incomplete**. `put` refuses while incomplete (no new note on a partial +//! write; fail-closed). Incomplete pairs are **left on disk** (data permanence); +//! they are never auto-pruned. A complete pair is only reported when both +//! files exist. +//! +//! ## Concurrency (single process) +//! +//! - **Root `RwLock`:** reserved for future exclusive operators; `put` takes a +//! read lock so exclusive work cannot interleave with mutation. +//! - **Per-blob `Mutex`:** concurrent puts of the same content address are +//! serialised. Parallel idempotent uploads of the same bytes all succeed +//! (loser waits for the complete pair). Lock map entries are removed when +//! no waiter holds the Arc anymore — so one-shot id touches cannot grow +//! process memory without bound. +//! +//! ## BLOSSOM_MULTI_INSTANCE_BOUNDARY (named follow-up; not fixed here) +//! +//! The locks above are **process-local** only. Multiple API processes sharing +//! one store root are **not** coordinated by this implementation. Safe +//! multi-instance deployment requires either single-writer affinity to the +//! store root or an external shared lock manager / atomic blob+note +//! publication — do not scale out against a shared filesystem without that. +//! Tracking: deployment-topology follow-up block, not this PR. + +use crate::error::ApiError; +use crate::hexutil::encode_hex; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Exactly 64 lowercase hex characters (32 decoded bytes). +pub const BLOB_ID_HEX_LEN: usize = 64; + +static TMP_SEQ: AtomicU64 = AtomicU64::new(0); + +/// Content-addressed store rooted at `root`. +#[derive(Debug)] +pub struct BlobStore { + root: PathBuf, + /// See module docs — exclusive operators (write) vs put (read). + root_lock: RwLock<()>, + /// Per-blob serialisation of put. + /// + /// Entries are created on demand and **removed** when the last holder + /// finishes (`release_blob_lock`), so the map cannot grow unboundedly + /// from one-shot id touches. + blob_locks: Mutex>>>, +} + +impl BlobStore { + /// Open (or create) a store at `root`. No default path — the caller must + /// supply a configured root. Does **not** prune incomplete pairs (data + /// permanence). + pub fn open(root: impl Into) -> Result { + let root = root.into(); + fs::create_dir_all(&root).map_err(|e| { + ApiError::internal(format!( + "blossom store: cannot create root {}: {e}", + root.display() + )) + })?; + let meta = fs::metadata(&root).map_err(|e| { + ApiError::internal(format!( + "blossom store: cannot stat root {}: {e}", + root.display() + )) + })?; + if !meta.is_dir() { + return Err(ApiError::internal(format!( + "blossom store: root {} is not a directory", + root.display() + ))); + } + Ok(Self { + root, + root_lock: RwLock::new(()), + blob_locks: Mutex::new(HashMap::new()), + }) + } + + /// Filesystem root (tests / diagnostics). + pub fn root(&self) -> &Path { + &self.root + } + + /// Parse a path parameter into a content address. + /// + /// Accepts **only** exactly 64 lowercase ASCII hex characters. Everything + /// else — wrong length, uppercase, non-hex, separators — is `400` and + /// never becomes a path component. + pub fn parse_blob_id(param: &str) -> Result<[u8; 32], ApiError> { + if param.len() != BLOB_ID_HEX_LEN { + return Err(ApiError::malformed(format!( + "blob path parameter must be exactly {BLOB_ID_HEX_LEN} lowercase hex characters, got {}", + param.len() + ))); + } + if !param + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + { + return Err(ApiError::malformed( + "blob path parameter must be lowercase hex [0-9a-f] only \ + (uppercase, separators, and non-hex are rejected before any path join)", + )); + } + let mut out = [0u8; 32]; + let bytes = param.as_bytes(); + for i in 0..32 { + let hi = nibble(bytes[i * 2]); + let lo = nibble(bytes[i * 2 + 1]); + out[i] = (hi << 4) | lo; + } + Ok(out) + } + + /// Lowercase-hex form of a blob id (the only form used as a filename). + pub fn blob_id_hex(id: &[u8; 32]) -> String { + encode_hex(id) + } + + fn blob_path(&self, id: &[u8; 32]) -> PathBuf { + self.root.join(Self::blob_id_hex(id)) + } + + fn uploader_path(&self, id: &[u8; 32]) -> PathBuf { + self.root + .join(format!("{}.uploader", Self::blob_id_hex(id))) + } + + /// Acquire the per-blob serialisation lock (creates the map entry if needed). + fn acquire_blob_lock(&self, id: &[u8; 32]) -> Arc> { + let mut map = self.blob_locks.lock().unwrap_or_else(|e| e.into_inner()); + map.entry(*id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + /// Drop the caller's Arc and remove the map entry when no other holder + /// remains. Must be called **after** the per-blob `Mutex` guard is dropped. + /// + /// Under the map lock, `strong_count == 2` means only the map entry and + /// `held` reference this Arc (any concurrent acquirer would have bumped + /// the count while holding the map lock). Removal is then race-free. + fn release_blob_lock(&self, id: &[u8; 32], held: Arc>) { + let mut map = self.blob_locks.lock().unwrap_or_else(|e| e.into_inner()); + if Arc::strong_count(&held) == 2 { + if let Some(current) = map.get(id) { + if Arc::ptr_eq(current, &held) { + map.remove(id); + } + } + } + // `held` drops at end of scope; after a successful remove the map no + // longer retains the Arc. + drop(held); + } + + /// Run `f` under the per-blob lock, then clean up the map entry if unused. + fn with_blob_lock(&self, id: &[u8; 32], f: impl FnOnce() -> R) -> R { + let arc = self.acquire_blob_lock(id); + let result = { + let _guard = arc.lock().unwrap_or_else(|e| e.into_inner()); + f() + }; + self.release_blob_lock(id, arc); + result + } + + /// Test/diagnostic: number of live per-blob lock map entries. + #[cfg(test)] + fn blob_lock_entry_count(&self) -> usize { + self.blob_locks + .lock() + .unwrap_or_else(|e| e.into_inner()) + .len() + } + + /// `true` when a **complete** durable pair (blob + note) exists. + pub fn exists(&self, id: &[u8; 32]) -> bool { + self.blob_path(id).is_file() && self.uploader_path(id).is_file() + } + + /// Byte length of a stored blob, or `None` if the complete pair is absent. + pub fn size(&self, id: &[u8; 32]) -> Result, ApiError> { + if !self.exists(id) { + return Ok(None); + } + let path = self.blob_path(id); + match fs::metadata(&path) { + Ok(m) if m.is_file() => Ok(Some(m.len())), + Ok(_) => Err(ApiError::internal(format!( + "blossom store: path {} is not a regular file", + path.display() + ))), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(ApiError::internal(format!( + "blossom store: stat {}: {e}", + path.display() + ))), + } + } + + /// Read the full blob body, or `None` if the complete pair is absent. + pub fn read(&self, id: &[u8; 32]) -> Result>, ApiError> { + if !self.exists(id) { + return Ok(None); + } + let path = self.blob_path(id); + match fs::read(&path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(ApiError::internal(format!( + "blossom store: read {}: {e}", + path.display() + ))), + } + } + + /// Read the original uploader's `op` pubkey, or `None` if the note is + /// absent. Incomplete pairs are fail-closed for readers (`exists`/`read`). + pub fn read_uploader(&self, id: &[u8; 32]) -> Result, ApiError> { + let path = self.uploader_path(id); + let text = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(ApiError::internal(format!( + "blossom store: read uploader note {}: {e}", + path.display() + ))); + } + }; + let text = text.trim(); + let id = Self::parse_blob_id(text).map_err(|e| { + ApiError::internal(format!( + "blossom store: corrupt uploader note {}: {}", + path.display(), + e.body.message + )) + })?; + Ok(Some(id)) + } + + /// Store `body` under `blob_id = H(body)`. Idempotent when a **complete** + /// pair already exists: body is not rewritten and the uploader note is + /// left alone (first-uploader wins). + /// + /// Concurrent puts of the same content are serialised on a per-blob lock; + /// losers that observe a complete pair return success. + pub fn put(&self, body: &[u8], uploader_op: &[u8; 32]) -> Result<[u8; 32], ApiError> { + let id: [u8; 32] = Sha256::digest(body).into(); + + // Root read lock: exclusive operators (write) cannot run while put is active. + let _root = self.root_lock.read().unwrap_or_else(|e| e.into_inner()); + self.with_blob_lock(&id, || self.put_locked(body, uploader_op, &id)) + } + + fn put_locked( + &self, + body: &[u8], + uploader_op: &[u8; 32], + id: &[u8; 32], + ) -> Result<[u8; 32], ApiError> { + let final_path = self.blob_path(id); + let note_path = self.uploader_path(id); + + // Complete pair: first-uploader wins; do not rewrite note. + if final_path.is_file() && note_path.is_file() { + return Ok(*id); + } + + // Incomplete pair under the exclusive blob lock can only be a + // crash leftover — refuse so a foreign retry cannot claim ownership. + // Data permanence: incomplete objects are never auto-pruned. + if final_path.is_file() || note_path.is_file() { + return Err(ApiError::internal( + "blossom store: incomplete blob/note pair present; \ + refuse put (data permanence: incomplete objects are never deleted)", + )); + } + + let tag = unique_tmp_tag(); + let hex = Self::blob_id_hex(id); + let blob_tmp = self.root.join(format!(".{hex}.blob.tmp.{tag}")); + let note_tmp = self.root.join(format!(".{hex}.note.tmp.{tag}")); + + if let Err(e) = write_exclusive(&blob_tmp, body) { + let _ = fs::remove_file(&blob_tmp); + return Err(ApiError::internal(format!( + "blossom store: write blob temp {}: {e}", + blob_tmp.display() + ))); + } + let note_hex = encode_hex(uploader_op); + if let Err(e) = write_exclusive(¬e_tmp, note_hex.as_bytes()) { + let _ = fs::remove_file(&blob_tmp); + let _ = fs::remove_file(¬e_tmp); + return Err(ApiError::internal(format!( + "blossom store: write note temp {}: {e}", + note_tmp.display() + ))); + } + + match install_no_replace(&blob_tmp, &final_path) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + let _ = fs::remove_file(¬e_tmp); + // Under per-blob lock this should not race another put, but + // if a complete pair appeared, treat as idempotent success. + if note_path.is_file() && final_path.is_file() { + return Ok(*id); + } + return Err(ApiError::internal( + "blossom store: blob slot occupied without complete pair; \ + refuse put (data permanence: incomplete objects are never deleted)", + )); + } + Err(e) => { + let _ = fs::remove_file(¬e_tmp); + return Err(ApiError::internal(format!( + "blossom store: install blob {}: {e}", + final_path.display() + ))); + } + } + + match install_no_replace(¬e_tmp, ¬e_path) { + Ok(()) => Ok(*id), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + if note_path.is_file() { + Ok(*id) + } else { + // Data permanence: do not roll back the installed blob. + Err(ApiError::internal(format!( + "blossom store: install note race on {} (blob retained): {e}", + note_path.display() + ))) + } + } + Err(e) => { + // Data permanence: do not roll back the installed blob. + // Incomplete pair remains; subsequent put refuses. + Err(ApiError::internal(format!( + "blossom store: install note {} (blob retained): {e}", + note_path.display() + ))) + } + } + } + + /// Test/diagnostic: list names of regular files directly under the root. + #[cfg(test)] + pub fn list_root_names(&self) -> Result, ApiError> { + let mut names = Vec::new(); + let rd = fs::read_dir(&self.root).map_err(|e| { + ApiError::internal(format!( + "blossom store: read_dir {}: {e}", + self.root.display() + )) + })?; + for entry in rd { + let entry = entry + .map_err(|e| ApiError::internal(format!("blossom store: read_dir entry: {e}")))?; + if let Some(name) = entry.file_name().to_str() { + names.push(name.to_string()); + } + } + names.sort(); + Ok(names) + } +} + +fn nibble(b: u8) -> u8 { + match b { + b'0'..=b'9' => b - b'0', + b'a'..=b'f' => b - b'a' + 10, + _ => unreachable!("caller validated lowercase hex"), + } +} + +fn unique_tmp_tag() -> String { + let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{}-{}-{}", std::process::id(), nanos, seq) +} + +fn write_exclusive(path: &Path, bytes: &[u8]) -> io::Result<()> { + let mut f = OpenOptions::new().write(true).create_new(true).open(path)?; + f.write_all(bytes)?; + f.sync_all()?; + drop(f); + let _ = File::open(path.parent().unwrap_or(Path::new("."))).and_then(|d| d.sync_all()); + Ok(()) +} + +fn install_no_replace(tmp: &Path, final_path: &Path) -> io::Result<()> { + match fs::hard_link(tmp, final_path) { + Ok(()) => { + let _ = fs::remove_file(tmp); + Ok(()) + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => { + let _ = fs::remove_file(tmp); + Err(e) + } + Err(e) => { + let _ = fs::remove_file(tmp); + Err(e) + } + } +} + +/// SHA-256 of raw bytes — the normative `blob_id` (§4.2.1). +pub fn blob_id_of(body: &[u8]) -> [u8; 32] { + Sha256::digest(body).into() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + fn temp_root() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "zkcoins-blossom-store-{}-{}", + std::process::id(), + nanos + )); + let _ = fs::remove_dir_all(&root); + root + } + + #[test] + fn parse_blob_id_accepts_exact_lowercase_hex() { + let hex = "a".repeat(64); + let id = BlobStore::parse_blob_id(&hex).expect("valid"); + assert_eq!(id, [0xaa; 32]); + } + + #[test] + fn parse_blob_id_rejects_uppercase() { + let hex = "A".repeat(64); + let err = BlobStore::parse_blob_id(&hex).expect_err("uppercase"); + assert_eq!(err.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!(err.body.error, "malformed_request"); + } + + #[test] + fn parse_blob_id_rejects_wrong_lengths() { + for bad in [ + "a".repeat(63), + "a".repeat(65), + String::new(), + "zz".to_string(), + ] { + let err = BlobStore::parse_blob_id(&bad).expect_err("bad length/chars"); + assert_eq!(err.body.error, "malformed_request"); + } + } + + #[test] + fn put_get_roundtrip_and_idempotent() { + let root = temp_root(); + let store = BlobStore::open(&root).expect("open"); + let body = b"hello blossom ciphertext"; + let uploader = [0x11u8; 32]; + let id = store.put(body, &uploader).expect("put"); + assert_eq!(id, blob_id_of(body)); + let got = store.read(&id).expect("read").expect("present"); + assert_eq!(got, body); + assert_eq!(store.size(&id).expect("size"), Some(body.len() as u64)); + let other = [0x22u8; 32]; + let id2 = store.put(body, &other).expect("put again"); + assert_eq!(id2, id); + let note = store.read_uploader(&id).expect("note").expect("present"); + assert_eq!(note, uploader); + let _ = fs::remove_dir_all(&root); + } + + /// Incomplete pairs refuse put and are never auto-pruned on re-open. + #[test] + fn incomplete_blob_without_note_refuses_put_and_survives_reopen() { + let root = temp_root(); + let store = BlobStore::open(&root).expect("open"); + let body = b"orphan-blob-body"; + let id = blob_id_of(body); + fs::write(store.blob_path(&id), body).expect("orphan blob"); + assert!(store.read_uploader(&id).expect("read").is_none()); + assert!(!store.exists(&id)); + let uploader = [0x33u8; 32]; + let err = store + .put(body, &uploader) + .expect_err("put must refuse incomplete"); + assert_eq!(err.body.error, "internal_error"); + assert!( + store.blob_path(&id).is_file(), + "data permanence: incomplete blob must remain on disk" + ); + drop(store); + let store = BlobStore::open(&root).expect("re-open must not prune"); + assert!( + store.blob_path(&id).is_file(), + "re-open must not delete incomplete pairs" + ); + let err2 = store + .put(body, &uploader) + .expect_err("still incomplete after re-open"); + assert_eq!(err2.body.error, "internal_error"); + let _ = fs::remove_dir_all(&root); + } + + /// Complete objects stay readable after open; no path deletes them. + #[test] + fn complete_pair_survives_reopen() { + let root = temp_root(); + let store = BlobStore::open(&root).expect("open"); + let body = b"durable-blob"; + let uploader = [0x44u8; 32]; + let id = store.put(body, &uploader).expect("put"); + drop(store); + let store = BlobStore::open(&root).expect("re-open"); + assert!(store.exists(&id)); + assert_eq!(store.read(&id).unwrap().unwrap(), body); + assert_eq!(store.read_uploader(&id).unwrap().unwrap(), uploader); + let _ = fs::remove_dir_all(&root); + } + + /// One-shot put of many distinct ids must not retain per-id lock map entries. + #[test] + fn put_does_not_retain_blob_lock_entries() { + let root = temp_root(); + let store = BlobStore::open(&root).expect("open"); + let op = [0x66u8; 32]; + assert_eq!(store.blob_lock_entry_count(), 0); + for i in 0..64u32 { + let mut body = [0u8; 8]; + body[0..4].copy_from_slice(&i.to_le_bytes()); + store.put(&body, &op).expect("put"); + } + assert_eq!( + store.blob_lock_entry_count(), + 0, + "put must release per-blob lock map entries" + ); + let _ = fs::remove_dir_all(&root); + } + + /// Parallel puts of the **same** content: **all** succeed (serialised); + /// single uploader note wins. + #[test] + fn parallel_puts_same_bytes_all_succeed() { + let root = temp_root(); + let store = Arc::new(BlobStore::open(&root).expect("open")); + let body = b"parallel-same-bytes"; + let mut handles = Vec::new(); + for i in 0..8u8 { + let store = Arc::clone(&store); + handles.push(thread::spawn(move || { + let mut op = [0u8; 32]; + op[0] = i; + store.put(body, &op) + })); + } + let mut oks = 0; + for h in handles { + h.join() + .expect("thread") + .expect("every parallel put must succeed"); + oks += 1; + } + assert_eq!(oks, 8, "all parallel puts must succeed"); + let id = blob_id_of(body); + let note = store + .read_uploader(&id) + .expect("note") + .expect("complete pair must have a note"); + assert_eq!(store.read(&id).unwrap().unwrap(), body); + let late = store.put(body, &[0xff; 32]).expect("late put"); + assert_eq!(late, id); + assert_eq!( + store.read_uploader(&id).unwrap().unwrap(), + note, + "first complete uploader note must win" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn parallel_puts_distinct_uploaders_and_bodies() { + let root = temp_root(); + let store = Arc::new(BlobStore::open(&root).expect("open")); + let mut handles = Vec::new(); + for i in 0..8u8 { + let store = Arc::clone(&store); + handles.push(thread::spawn(move || { + let body = vec![i; 32]; + let mut op = [0u8; 32]; + op[0] = i; + op[1] = 0xaa; + let id = store.put(&body, &op)?; + Ok::<_, ApiError>((id, op, body)) + })); + } + for h in handles { + let (id, op, body) = h.join().expect("thread").expect("put"); + assert_eq!(id, blob_id_of(&body)); + assert_eq!(store.read_uploader(&id).unwrap().unwrap(), op); + } + let _ = fs::remove_dir_all(&root); + } +} diff --git a/src/bootstrap.rs b/src/bootstrap.rs new file mode 100644 index 0000000..e6f4fcc --- /dev/null +++ b/src/bootstrap.rs @@ -0,0 +1,347 @@ +//! Bootstrap REST surface (§7.7): challenge, entrust, revoke. +//! +//! | Method | Path | Kernel | +//! |---|---|---| +//! | `POST` | `/v1/bootstrap/challenge` | `OpenPullChallenge` action=`entrust`\|`revoke` | +//! | `POST` | `/v1/bootstrap/entrust` | `EntrustOperationalBundle` (after OwnershipProof) | +//! | `POST` | `/v1/bootstrap/revoke` | `RevokeOperationalBundle` (after OwnershipProof) | +//! +//! ## Domain binding +//! +//! Issuance takes `action` in the body and returns the matching domain +//! (`zkCoins/v1/EntrustChallenge` / `zkCoins/v1/RevokeChallenge`). Redeem is +//! **endpoint-bound**: `/entrust` always verifies under Entrust, `/revoke` +//! under Revoke — a proof signed for one cannot authorise the other. +//! +//! ## Secrets +//! +//! `POST /v1/bootstrap/entrust` carries `serialize(OperationalBundle)` (161 +//! bytes / five 256-bit secrets). This module never logs the hex, never puts +//! it in an error message, and never includes it in `Debug` output of any +//! type that outlives the parse. Length and hex form are checked **before** +//! the kernel is dialed; a bad body fails at the edge with length/form only. + +use crate::error::ApiError; +use crate::extract::JsonBody; +use crate::hexutil::encode_hex; +use crate::kernel::kernel_v1::{ + EntrustRequest, EntrustResult, PullChallengeRequest, RevokeRequest, RevokeResult, +}; +use crate::ownership::{ + decode_zk_address, verify_simple_ownership_proof, ChallengeDomain, ChallengeEcho, + OwnerOnlyProofJson, ENTRUST_CHALLENGE_DOMAIN, REVOKE_CHALLENGE_DOMAIN, +}; +use crate::state::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::json; + +/// Normative fixed length of `serialize(OperationalBundle)` (§7.7 / node +/// `OPERATIONAL_BUNDLE_LEN`): version(1) ‖ five × 32-byte secrets = 161. +pub const OPERATIONAL_BUNDLE_LEN: usize = 161; + +/// Hex character count for a 161-byte bundle (``). +pub const OPERATIONAL_BUNDLE_HEX_CHARS: usize = OPERATIONAL_BUNDLE_LEN * 2; + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct BootstrapChallengeBody { + pub subject: String, + /// `"entrust"` or `"revoke"` — maps to kernel `OpenPullChallenge.action`. + pub action: String, +} + +/// Entrust redeem body. **`Debug` redacts `bundle`** so a logger that prints +/// the extractor cannot spill five operational secrets. +#[derive(Deserialize)] +pub struct BootstrapEntrustBody { + /// Redeem-body `expiry` (§7.5 normative): `{ nonce, expiry }` from issuance. + pub challenge: ChallengeEcho, + pub ownership_proof: OwnerOnlyProofJson, + /// 161-byte `serialize(OperationalBundle)` as hex (``). + /// + /// **Never log this field.** It holds five 256-bit operational secrets. + pub bundle: String, +} + +impl std::fmt::Debug for BootstrapEntrustBody { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BootstrapEntrustBody") + .field("challenge", &self.challenge) + .field("ownership_proof", &self.ownership_proof) + .field("bundle", &"") + .finish() + } +} + +#[derive(Debug, Deserialize)] +pub struct BootstrapRevokeBody { + /// Redeem-body `expiry` (§7.5 normative): `{ nonce, expiry }` from issuance. + pub challenge: ChallengeEcho, + pub ownership_proof: OwnerOnlyProofJson, +} + +// --------------------------------------------------------------------------- +// Bundle parse (no secret in errors) +// --------------------------------------------------------------------------- + +/// Decode and length-check the operational bundle hex. +/// +/// Error messages name only the **length** or the **form class** (odd length, +/// non-hex nibble). The raw hex string is **never** interpolated into the +/// message — a distinctive secret hex must not leak through 400 responses. +fn parse_operational_bundle_hex(hex: &str) -> Result, ApiError> { + // Exact character count first: wrong length is the common client mistake + // and must not fall through to a per-nibble walk that could be logged. + if hex.len() != OPERATIONAL_BUNDLE_HEX_CHARS { + return Err(ApiError::malformed(format!( + "bundle must be exactly {OPERATIONAL_BUNDLE_HEX_CHARS} hex characters \ + ({OPERATIONAL_BUNDLE_LEN} bytes); got {} characters", + hex.len() + ))); + } + // Manual nibble decode so we never surface the input string on failure. + let bytes = hex.as_bytes(); + let mut out = Vec::with_capacity(OPERATIONAL_BUNDLE_LEN); + let mut i = 0; + while i < bytes.len() { + let hi = match hex_nibble(bytes[i]) { + Some(v) => v, + None => { + return Err(ApiError::malformed( + "bundle is not valid hex (non-hex character at even nibble offset)", + )); + } + }; + let lo = match hex_nibble(bytes[i + 1]) { + Some(v) => v, + None => { + return Err(ApiError::malformed( + "bundle is not valid hex (non-hex character at odd nibble offset)", + )); + } + }; + out.push((hi << 4) | lo); + i += 2; + } + debug_assert_eq!(out.len(), OPERATIONAL_BUNDLE_LEN); + Ok(out) +} + +fn hex_nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /v1/bootstrap/challenge` → OpenPullChallenge(action=entrust|revoke). +pub async fn post_bootstrap_challenge( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + if body.subject.is_empty() { + return Err(ApiError::malformed("subject is required")); + } + let _ = decode_zk_address(&body.subject)?; + + let (action_wire, expected_domain) = match body.action.as_str() { + "entrust" => ("entrust", ENTRUST_CHALLENGE_DOMAIN), + "revoke" => ("revoke", REVOKE_CHALLENGE_DOMAIN), + other => { + return Err(ApiError::malformed(format!( + "action must be \"entrust\" or \"revoke\", got {other:?}" + ))); + } + }; + + let challenge = state + .kernel + .open_pull_challenge(PullChallengeRequest { + subject: body.subject, + requested_scope: None, + action: action_wire.to_string(), + }) + .await?; + + if challenge.nonce.len() != 32 { + return Err(ApiError::internal(format!( + "kernel Challenge.nonce must be 32 bytes, got {}", + challenge.nonce.len() + ))); + } + // Domain is action-bound at issuance: refuse a kernel that returns a + // foreign tag (would let a client sign under the wrong domain). + if challenge.domain != expected_domain { + return Err(ApiError::internal(format!( + "kernel Challenge.domain must be {expected_domain:?} for action {action_wire:?}, \ + got {:?}", + challenge.domain + ))); + } + + let body = json!({ + "nonce": encode_hex(&challenge.nonce), + "expiry": challenge.expiry.to_string(), + "domain": expected_domain, + }); + Ok((StatusCode::OK, Json(body)).into_response()) +} + +/// `POST /v1/bootstrap/entrust` → verify OwnershipProof (Entrust domain), then +/// `EntrustOperationalBundle`. +/// +/// Verification and bundle length/form run **before** any kernel call so a +/// bad signature cannot burn the single-use nonce and a 160/162-byte hex +/// never leaves this process as a secret-bearing RPC payload. +pub async fn post_bootstrap_entrust( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + // ---- pure validation (no kernel) ---- + // Destructure so the hex `bundle` string is dropped before the kernel + // await (only `bundle_bytes` remains). + let BootstrapEntrustBody { + challenge, + ownership_proof, + bundle, + } = body; + // Bundle first: reject wrong width without touching the challenge store. + // `parse_operational_bundle_hex` never interpolates the hex into errors. + let bundle_bytes = parse_operational_bundle_hex(&bundle)?; + drop(bundle); + + // GrantProof arm → 401; Ownership arm carries the subject (no outer field). + let ownership_proof = ownership_proof.require_ownership()?; + let subject = ownership_proof.subject.clone(); + if subject.is_empty() { + return Err(ApiError::malformed("ownership_proof.subject is required")); + } + + // Domain is the **endpoint** constant — not body.action, not body.domain. + let verified = verify_simple_ownership_proof( + ChallengeDomain::Entrust, + &subject, + &challenge, + &ownership_proof, + state.public_hosts.as_slice(), + )?; + + // ---- only now: kernel (nonce consumption lives here) ---- + let result: EntrustResult = state + .kernel + .entrust_operational_bundle(EntrustRequest { + nonce: verified.nonce.to_vec(), + subject: verified.subject_bech32, + bundle: bundle_bytes, + chan_bind: verified.chan_bind.to_vec(), + }) + .await?; + + let body = json!({ "accepted": result.accepted }); + Ok((StatusCode::OK, Json(body)).into_response()) +} + +/// `POST /v1/bootstrap/revoke` → verify OwnershipProof (Revoke domain), then +/// `RevokeOperationalBundle`. +pub async fn post_bootstrap_revoke( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + let ownership_proof = body.ownership_proof.require_ownership()?; + let subject = ownership_proof.subject.clone(); + if subject.is_empty() { + return Err(ApiError::malformed("ownership_proof.subject is required")); + } + + let verified = verify_simple_ownership_proof( + ChallengeDomain::Revoke, + &subject, + &body.challenge, + &ownership_proof, + state.public_hosts.as_slice(), + )?; + + let result: RevokeResult = state + .kernel + .revoke_operational_bundle(RevokeRequest { + nonce: verified.nonce.to_vec(), + subject: verified.subject_bech32, + chan_bind: verified.chan_bind.to_vec(), + }) + .await?; + + let body = json!({ "revoked": result.revoked }); + Ok((StatusCode::OK, Json(body)).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundle_len_constants_match_spec() { + assert_eq!(OPERATIONAL_BUNDLE_LEN, 161); + assert_eq!(OPERATIONAL_BUNDLE_HEX_CHARS, 322); + } + + #[test] + fn bundle_wrong_length_does_not_echo_hex() { + // 160 bytes = 320 hex chars — distinctive secret pattern must not + // appear in the error message. + let secret = "ab".repeat(160); + assert_eq!(secret.len(), 320); + let err = parse_operational_bundle_hex(&secret).expect_err("160 bytes"); + assert_eq!(err.body.error, "malformed_request"); + assert!( + !err.body.message.contains(&secret), + "error must not contain the bundle hex: {}", + err.body.message + ); + assert!( + err.body.message.contains("320") || err.body.message.contains("322"), + "error should report character counts: {}", + err.body.message + ); + + let secret162 = "cd".repeat(162); + let err = parse_operational_bundle_hex(&secret162).expect_err("162 bytes"); + assert!(!err.body.message.contains(&secret162)); + } + + #[test] + fn bundle_161_bytes_accepted() { + let hex = "01".to_string() + &"00".repeat(160); + assert_eq!(hex.len(), 322); + let bytes = parse_operational_bundle_hex(&hex).expect("161 bytes"); + assert_eq!(bytes.len(), 161); + assert_eq!(bytes[0], 0x01); + } + + #[test] + fn bundle_non_hex_does_not_echo_input() { + let mut hex = "ee".repeat(161); + // Force a non-hex character in the middle without changing length. + hex.replace_range(100..102, "zz"); + let err = parse_operational_bundle_hex(&hex).expect_err("non-hex"); + assert_eq!(err.body.error, "malformed_request"); + assert!( + !err.body.message.contains("zz"), + "error must not echo the bad nibble context: {}", + err.body.message + ); + assert!(!err.body.message.contains(&hex)); + } +} diff --git a/src/chain.rs b/src/chain.rs new file mode 100644 index 0000000..773f2df --- /dev/null +++ b/src/chain.rs @@ -0,0 +1,1127 @@ +//! Public chain read surface (§7.5 L2878–L2880) over kernel procedures. +//! +//! | REST | Kernel | +//! |---|---| +//! | `GET /v1/chain/accumulator` | `GetAccumulator` | +//! | `GET /v1/chain/inscriptions` | `ListInscriptions` (server-stream → one page) | +//! | `GET /v1/chain/nullifier/` | `GetNullifierPath` | +//! +//! The api **does not recompute** `nav_root = Hc("NfLog/Root", size ‖ mth)`. +//! Every `root` byte is what the kernel returned. Width checks reject a +//! malformed kernel payload; they never invent a substitute digest. + +use crate::error::ApiError; +use crate::hexutil::{decode_hex_exact, encode_hex}; +use crate::kernel::kernel_v1::{ + AccumulatorTip, Inscription, ListInscriptionsRequest, Nullifier, NullifierPath, + NullifierPathRequest, +}; +use crate::kernel::KernelHandle; +use axum::extract::{Path, RawQuery, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use futures_util::StreamExt; +use serde_json::{json, Map, Value}; + +/// Inclusive lower bound + page size after REST query normalisation (§7.5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ListInscriptionsQuery { + from_height: u64, + from_tx_index: u64, + from_vin_index: u64, + /// Client page size; valid range `1..=1000` (enforced at parse). + limit: u32, +} + +/// Exclusive triple-cursor after the last returned inscription (§7.5). +/// +/// Structural all-or-nothing: the REST body either carries all three `next_*` +/// fields (this value is `Some`) or none of them (`None`). A proper subset is +/// unrepresentable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TripleCursor { + height: u64, + tx_index: u64, + vin_index: u64, +} + +impl TripleCursor { + fn from_inscription(ins: &Inscription) -> Self { + Self { + height: ins.height, + tx_index: ins.tx_index, + vin_index: ins.vin_index, + } + } + + /// Lexicographic successor of this triple — the inclusive `from_*` that + /// starts strictly after `self`. Used only for the limit=1000 peek path. + fn exclusive_successor(self) -> Result { + if self.vin_index < u64::MAX { + return Ok(Self { + height: self.height, + tx_index: self.tx_index, + vin_index: self.vin_index + 1, + }); + } + if self.tx_index < u64::MAX { + return Ok(Self { + height: self.height, + tx_index: self.tx_index + 1, + vin_index: 0, + }); + } + if self.height < u64::MAX { + return Ok(Self { + height: self.height + 1, + tx_index: 0, + vin_index: 0, + }); + } + Err(ApiError::internal( + "inscription triple cursor cannot advance past (u64::MAX, u64::MAX, u64::MAX)", + )) + } +} + +/// One REST page: inscriptions plus an optional all-or-nothing next cursor. +/// +/// `PartialEq` only — prost `Inscription` is not `Eq`, and this type is never +/// used as a map/set key. Ordering of `inscriptions` is the kernel's contract +/// (§7.8), checked rather than re-derived. +#[derive(Debug, Clone, PartialEq)] +struct InscriptionsPage { + inscriptions: Vec, + next: Option, +} + +/// Named kernel-limit translation for pagination (`PAGE_LOOKAHEAD`). +/// +/// REST `limit` is the number of inscriptions on the page. To learn whether a +/// further page exists, the API must see one item beyond that page. When +/// `rest_limit < MAX_LIMIT` (strictly below the kernel's closed max), the kernel +/// receives `rest_limit + 1` in a single `ListInscriptions` call — that is the +/// **page-lookahead** translation: deliberate, named, and never a silent +/// clamp of the client value. At `rest_limit == MAX_LIMIT` the kernel cannot +/// accept `MAX_LIMIT + 1`, so the handler requests exactly `MAX_LIMIT` and, only +/// if the stream is full, issues a second **peek** RPC with `limit = 1` from +/// the exclusive successor of the last returned triple. +/// +// §7.5 `GET /v1/chain/inscriptions` query defaults (normative; API-normalised +// before RPC when the REST query omits them). Named constants — not +// `unwrap_or_default()` — so the literal value and its protocol origin stay +// visible at every use site. +/// §7.5 `GET /v1/chain/inscriptions`: `from_height` optional, default 0. +const DEFAULT_FROM_HEIGHT: u64 = 0; +/// §7.5: `from_tx_index` optional, default 0. +const DEFAULT_FROM_TX_INDEX: u64 = 0; +/// §7.5: `from_vin_index` optional, default 0. +const DEFAULT_FROM_VIN_INDEX: u64 = 0; +/// §7.5: `limit` optional, default 100; valid range `1..=1000`. +const DEFAULT_LIMIT: u32 = 100; +/// §7.5: lower bound of valid `limit` (inclusive). +const MIN_LIMIT: u32 = 1; +/// §7.5: upper bound of valid `limit` (inclusive). +const MAX_LIMIT: u32 = 1000; + +/// `GET /v1/chain/accumulator` → `GetAccumulator`. +/// +/// Response form §7.5 L2878: `{ size, root, tip_block_hash, tip_height }`. +/// `root` is the kernel's `nav_root` — pass-through, not recomputed. +pub async fn get_accumulator(State(kernel): State) -> Result { + let tip = kernel.get_accumulator().await?; + let body = accumulator_to_json(&tip)?; + Ok((StatusCode::OK, Json(body)).into_response()) +} + +/// `GET /v1/chain/inscriptions` → `ListInscriptions` (stream collected into one page). +/// +/// Query/response form §7.5 L2879. Empty catalogue → `{ "inscriptions": [] }` +/// with no `next_*` fields (200, never 404). +pub async fn list_inscriptions( + State(kernel): State, + RawQuery(raw): RawQuery, +) -> Result { + let query = parse_list_inscriptions_query(raw.as_deref())?; + let page = fetch_inscriptions_page(&kernel, query).await?; + let body = inscriptions_page_to_json(&page)?; + Ok((StatusCode::OK, Json(body)).into_response()) +} + +/// `GET /v1/chain/nullifier/` → `GetNullifierPath`. +/// +/// Response form §7.5 L2880. **present** and **absent** are distinct domain +/// answers from the kernel's `present` flag: +/// - `present: true` → inclusion proof fields (`position`, `leaf`, `audit_path`) +/// - `present: false` → unauthenticated local-index absence (no position/leaf) +/// +/// A kernel `internal_error` (e.g. corrupt index) is returned as that error +/// via `ErrorInfo` — **never** rewritten as `present: false`. Absence is only +/// the successful path with `present == false`. +pub async fn get_nullifier( + State(kernel): State, + Path(pubkey_hex): Path, +) -> Result { + let pubkey = decode_hex_exact(&pubkey_hex, 32).map_err(|e| { + ApiError::malformed(format!("pubkey path segment must be 32-byte hex: {e}")) + })?; + let path = kernel + .get_nullifier_path(NullifierPathRequest { pubkey }) + .await?; + let body = nullifier_path_to_json(&path)?; + Ok((StatusCode::OK, Json(body)).into_response()) +} + +// --------------------------------------------------------------------------- +// Query parse (malformed vs bounds_exceeded) +// --------------------------------------------------------------------------- + +fn parse_list_inscriptions_query(raw: Option<&str>) -> Result { + let mut from_height: Option = None; + let mut from_tx_index: Option = None; + let mut from_vin_index: Option = None; + let mut limit: Option = None; + + if let Some(q) = raw { + for pair in q.split('&') { + if pair.is_empty() { + continue; + } + let (key, value) = match pair.split_once('=') { + Some((k, v)) => (k, v), + None => (pair, ""), + }; + match key { + "from_height" => { + if from_height.is_some() { + return Err(ApiError::malformed("duplicate query parameter from_height")); + } + from_height = Some(parse_decimal_u64("from_height", value)?); + } + "from_tx_index" => { + if from_tx_index.is_some() { + return Err(ApiError::malformed( + "duplicate query parameter from_tx_index", + )); + } + from_tx_index = Some(parse_decimal_u64("from_tx_index", value)?); + } + "from_vin_index" => { + if from_vin_index.is_some() { + return Err(ApiError::malformed( + "duplicate query parameter from_vin_index", + )); + } + from_vin_index = Some(parse_decimal_u64("from_vin_index", value)?); + } + "limit" => { + if limit.is_some() { + return Err(ApiError::malformed("duplicate query parameter limit")); + } + limit = Some(parse_decimal_u32("limit", value)?); + } + _ => { + // Unknown query keys are ignored — only the closed set is + // interpreted; extra keys must not soft-fail the request. + } + } + } + } + + // §7.5 defaults for omitted parameters (API-normalised before RPC). + let from_height = from_height.unwrap_or(DEFAULT_FROM_HEIGHT); + let from_tx_index = from_tx_index.unwrap_or(DEFAULT_FROM_TX_INDEX); + let from_vin_index = from_vin_index.unwrap_or(DEFAULT_FROM_VIN_INDEX); + let limit = match limit { + None => DEFAULT_LIMIT, + Some(n) if n < MIN_LIMIT => { + return Err(ApiError::bounds_exceeded(format!( + "limit must be in {MIN_LIMIT}..={MAX_LIMIT}; got {n}" + ))); + } + Some(n) if n > MAX_LIMIT => { + return Err(ApiError::bounds_exceeded(format!( + "limit must be in {MIN_LIMIT}..={MAX_LIMIT}; got {n}" + ))); + } + Some(n) => n, + }; + + Ok(ListInscriptionsQuery { + from_height, + from_tx_index, + from_vin_index, + limit, + }) +} + +fn parse_decimal_u64(name: &str, raw: &str) -> Result { + if raw.is_empty() { + return Err(ApiError::malformed(format!( + "{name} must be a non-empty decimal integer" + ))); + } + if !raw.bytes().all(|b| b.is_ascii_digit()) { + return Err(ApiError::malformed(format!( + "{name} must be a decimal integer, got {raw:?}" + ))); + } + // Leading zeros are fine for "0"; multi-digit with leading zeros still + // parse as the same integer (no alternate encoding). + raw.parse::() + .map_err(|_| ApiError::malformed(format!("{name} overflows u64: {raw:?}"))) +} + +fn parse_decimal_u32(name: &str, raw: &str) -> Result { + let v = parse_decimal_u64(name, raw)?; + u32::try_from(v).map_err(|_| ApiError::malformed(format!("{name} overflows u32: {raw:?}"))) +} + +// --------------------------------------------------------------------------- +// Page fetch (PAGE_LOOKAHEAD + optional peek at limit=1000) +// --------------------------------------------------------------------------- + +async fn fetch_inscriptions_page( + kernel: &KernelHandle, + query: ListInscriptionsQuery, +) -> Result { + let rest_limit = query.limit; + // PAGE_LOOKAHEAD: ask for one extra when the kernel can still accept it. + let kernel_limit = if rest_limit < MAX_LIMIT { + rest_limit + 1 + } else { + rest_limit + }; + + let collected = collect_stream( + kernel, + ListInscriptionsRequest { + from_height: Some(query.from_height), + from_tx_index: Some(query.from_tx_index), + from_vin_index: Some(query.from_vin_index), + limit: Some(kernel_limit), + }, + ) + .await?; + + let rest_limit_usize = rest_limit as usize; + + if collected.len() > rest_limit_usize { + // Lookahead item proves a further page; its triple is the exclusive next. + let next_ins = &collected[rest_limit_usize]; + let next = TripleCursor::from_inscription(next_ins); + let inscriptions = collected.into_iter().take(rest_limit_usize).collect(); + return Ok(InscriptionsPage { + inscriptions, + next: Some(next), + }); + } + + // At rest_limit == MAX_LIMIT the kernel cannot take MAX_LIMIT+1; if the + // stream filled the page exactly, peek one item from the exclusive successor. + if rest_limit == MAX_LIMIT && collected.len() == rest_limit_usize { + let last = match collected.last() { + Some(ins) => ins, + None => { + // limit is 1000 and len is 1000, so last is always present; + // this arm is unreachable by construction. + return Err(ApiError::internal( + "page-full inscription stream has no last element", + )); + } + }; + let peek_from = TripleCursor::from_inscription(last).exclusive_successor()?; + let peek = collect_stream( + kernel, + ListInscriptionsRequest { + from_height: Some(peek_from.height), + from_tx_index: Some(peek_from.tx_index), + from_vin_index: Some(peek_from.vin_index), + limit: Some(1), + }, + ) + .await?; + if let Some(first) = peek.first() { + return Ok(InscriptionsPage { + inscriptions: collected, + next: Some(TripleCursor::from_inscription(first)), + }); + } + } + + Ok(InscriptionsPage { + inscriptions: collected, + next: None, + }) +} + +async fn collect_stream( + kernel: &KernelHandle, + req: ListInscriptionsRequest, +) -> Result, ApiError> { + let mut stream = kernel.list_inscriptions(req).await?; + let mut out = Vec::new(); + while let Some(item) = stream.next().await { + out.push(item?); + } + // §7.8: the kernel stream is already in stable triple order. Do not + // re-sort — a violation is a kernel bug and must surface as internal_error. + require_strict_triple_order(&out)?; + Ok(out) +} + +/// Inscription triple used as the §7.5 / §7.8 sort and cursor key. +fn inscription_triple(ins: &Inscription) -> (u64, u64, u64) { + (ins.height, ins.tx_index, ins.vin_index) +} + +/// Reject a kernel stream that is not strictly increasing in +/// `(height, tx_index, vin_index)`. Equal or reversed neighbours mean the +/// kernel broke its §7.8 ordering contract; silent repair would hide that. +fn require_strict_triple_order(items: &[Inscription]) -> Result<(), ApiError> { + for pair in items.windows(2) { + let prev = inscription_triple(&pair[0]); + let next = inscription_triple(&pair[1]); + if prev >= next { + return Err(ApiError::internal(format!( + "kernel ListInscriptions stream is not strictly increasing in \ + (height, tx_index, vin_index): {prev:?} is not before {next:?}" + ))); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// JSON encoding +// --------------------------------------------------------------------------- + +fn accumulator_to_json(tip: &AccumulatorTip) -> Result { + Ok(json!({ + "size": tip.size, + "root": require_hex32(&tip.root, "root")?, + "tip_block_hash": require_hex32(&tip.tip_block_hash, "tip_block_hash")?, + "tip_height": tip.tip_height, + })) +} + +fn inscriptions_page_to_json(page: &InscriptionsPage) -> Result { + let mut inscriptions = Vec::with_capacity(page.inscriptions.len()); + for ins in &page.inscriptions { + inscriptions.push(inscription_to_json(ins)?); + } + + let mut obj = Map::new(); + obj.insert("inscriptions".to_string(), Value::Array(inscriptions)); + // Structural all-or-nothing: emit every next_* or none. + if let Some(next) = page.next { + obj.insert("next_height".to_string(), json!(next.height)); + obj.insert("next_tx_index".to_string(), json!(next.tx_index)); + obj.insert("next_vin_index".to_string(), json!(next.vin_index)); + } + Ok(Value::Object(obj)) +} + +fn inscription_to_json(ins: &Inscription) -> Result { + let mut nullifiers = Vec::with_capacity(ins.nullifiers.len()); + for (i, n) in ins.nullifiers.iter().enumerate() { + nullifiers.push(nullifier_member_to_json(n, i)?); + } + + // confirmation_state is reveal-tx depth only — never an aggregate of + // member states, never "failed" (§7.5 / §3.9). + match ins.confirmation_state.as_str() { + "pending" | "completed" => {} + other => { + return Err(ApiError::internal(format!( + "kernel Inscription.confirmation_state must be \"pending\" or \"completed\", got {other:?}" + ))); + } + } + + // format: 0x00 raw | 0x01 half-aggregated (§3.5); other values are not on the closed set. + if ins.format > 1 { + return Err(ApiError::internal(format!( + "kernel Inscription.format must be 0 (raw) or 1 (half-aggregated), got {}", + ins.format + ))); + } + + Ok(json!({ + "txid": require_hex32(&ins.txid, "txid")?, + "height": ins.height, + "tx_index": ins.tx_index, + "vin_index": ins.vin_index, + "count": ins.count, + "format": ins.format, + "nullifiers": nullifiers, + "confirmation_state": ins.confirmation_state, + })) +} + +fn nullifier_member_to_json(n: &Nullifier, index: usize) -> Result { + // Per-member §3.10 state — members of one aggregate MAY differ. + match n.state.as_str() { + "completed" | "pending" | "failed" => {} + other => { + return Err(ApiError::internal(format!( + "kernel Nullifier.state[{index}] must be \"completed\", \"pending\", or \"failed\", got {other:?}" + ))); + } + } + Ok(json!({ + "pubkey": require_hex32(&n.pubkey, &format!("nullifiers[{index}].pubkey"))?, + "r": require_hex32(&n.r, &format!("nullifiers[{index}].r"))?, + "state": n.state, + })) +} + +fn nullifier_path_to_json(path: &NullifierPath) -> Result { + let mut obj = Map::new(); + obj.insert("present".to_string(), Value::Bool(path.present)); + obj.insert( + "root".to_string(), + Value::String(require_hex32(&path.root, "root")?), + ); + obj.insert( + "tip_block_hash".to_string(), + Value::String(require_hex32(&path.tip_block_hash, "tip_block_hash")?), + ); + obj.insert("tip_height".to_string(), json!(path.tip_height)); + obj.insert("tree_size".to_string(), json!(path.tree_size)); + + if path.present { + // Inclusion proof fields — required when present (L2880). + if path.leaf.is_empty() { + return Err(ApiError::internal( + "kernel NullifierPath.present is true but leaf is empty", + )); + } + obj.insert("position".to_string(), json!(path.position)); + obj.insert( + "leaf".to_string(), + Value::String(require_hex32(&path.leaf, "leaf")?), + ); + let mut audit = Vec::with_capacity(path.audit_path.len()); + if path.audit_path.len() > 64 { + return Err(ApiError::internal(format!( + "kernel NullifierPath.audit_path exceeds 64 entries (got {})", + path.audit_path.len() + ))); + } + for (i, node) in path.audit_path.iter().enumerate() { + audit.push(Value::String(require_hex32( + node, + &format!("audit_path[{i}]"), + )?)); + } + obj.insert("audit_path".to_string(), Value::Array(audit)); + } else { + // Unauthenticated absence (L2880 / §3.7 Path B). position and leaf + // are omitted — not null, not zero. audit_path is the empty list. + // Proto may carry position=0 / leaf empty as scalar defaults; those + // must not appear on the REST wire as if they were proof material. + if !path.leaf.is_empty() { + return Err(ApiError::internal( + "kernel NullifierPath.present is false but leaf is non-empty", + )); + } + if !path.audit_path.is_empty() { + return Err(ApiError::internal( + "kernel NullifierPath.present is false but audit_path is non-empty", + )); + } + obj.insert("audit_path".to_string(), Value::Array(Vec::new())); + } + + Ok(Value::Object(obj)) +} + +fn require_hex32(bytes: &[u8], field: &str) -> Result { + if bytes.len() != 32 { + return Err(ApiError::internal(format!( + "kernel field {field} must be 32 bytes, got {}", + bytes.len() + ))); + } + Ok(encode_hex(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::kernel_v1::Nullifier as ProtoNullifier; + use crate::kernel::KernelRpc; + use async_trait::async_trait; + use futures_util::stream::{self, BoxStream}; + use std::sync::Arc; + + fn sample_nullifier(state: &str) -> ProtoNullifier { + ProtoNullifier { + pubkey: vec![0x11; 32], + r: vec![0x22; 32], + state: state.to_string(), + } + } + + fn sample_inscription( + height: u64, + tx_index: u64, + vin_index: u64, + confirmation_state: &str, + nullifiers: Vec, + ) -> Inscription { + let mut txid = vec![0u8; 32]; + // Asymmetric bytes so a byte-order reverse would fail hex equality. + for (i, b) in txid.iter_mut().enumerate() { + *b = (i as u8).wrapping_add(1); + } + Inscription { + txid, + height, + count: nullifiers.len() as u32, + format: 1, + nullifiers, + confirmation_state: confirmation_state.to_string(), + tx_index, + vin_index, + } + } + + #[test] + fn accumulator_pass_through_does_not_recompute_root() { + let tip = AccumulatorTip { + root: vec![0xAB; 32], + tip_block_hash: vec![0xCD; 32], + tip_height: 42, + size: 7, + }; + let json = accumulator_to_json(&tip).expect("json"); + assert_eq!(json["size"], 7); + assert_eq!(json["tip_height"], 42); + assert_eq!(json["root"].as_str().unwrap(), encode_hex(&[0xAB; 32])); + assert_eq!( + json["tip_block_hash"].as_str().unwrap(), + encode_hex(&[0xCD; 32]) + ); + } + + #[test] + fn present_path_includes_position_and_leaf() { + let path = NullifierPath { + root: vec![0x01; 32], + tip_height: 10, + present: true, + leaf: vec![0x02; 32], + position: 3, + audit_path: vec![vec![0x03; 32]], + tree_size: 4, + tip_block_hash: vec![0x04; 32], + }; + let json = nullifier_path_to_json(&path).expect("json"); + assert_eq!(json["present"], true); + assert_eq!(json["position"], 3); + assert_eq!(json["leaf"].as_str().unwrap().len(), 64); + assert_eq!(json["audit_path"].as_array().unwrap().len(), 1); + assert_eq!(json["tree_size"], 4); + } + + #[test] + fn absent_path_omits_position_and_leaf() { + let path = NullifierPath { + root: vec![0x01; 32], + tip_height: 10, + present: false, + leaf: Vec::new(), + position: 0, + audit_path: Vec::new(), + tree_size: 4, + tip_block_hash: vec![0x04; 32], + }; + let json = nullifier_path_to_json(&path).expect("json"); + assert_eq!(json["present"], false); + assert!(json.get("position").is_none()); + assert!(json.get("leaf").is_none()); + assert_eq!(json["audit_path"], json!([])); + assert_eq!(json["tree_size"], 4); + assert_eq!(json["root"].as_str().unwrap().len(), 64); + } + + #[test] + fn wrong_width_root_is_internal_not_silent_pad() { + let tip = AccumulatorTip { + root: vec![0xAB; 16], + tip_block_hash: vec![0xCD; 32], + tip_height: 1, + size: 0, + }; + let err = accumulator_to_json(&tip).expect_err("bad root"); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, crate::error::PUBLIC_INTERNAL_MESSAGE); + assert!( + err.cause().unwrap_or("").contains("root"), + "operator cause must name the field, got {:?}", + err.cause() + ); + } + + /// Failed member + completed confirmation in one inscription — the two + /// states are independent (§3.10 vs reveal-tx depth). + #[test] + fn failed_member_with_completed_confirmation_state() { + let ins = sample_inscription( + 100, + 2, + 0, + "completed", + vec![sample_nullifier("completed"), sample_nullifier("failed")], + ); + let json = inscription_to_json(&ins).expect("json"); + assert_eq!(json["confirmation_state"], "completed"); + let nullifiers = json["nullifiers"].as_array().expect("nullifiers"); + assert_eq!(nullifiers.len(), 2); + assert_eq!(nullifiers[0]["state"], "completed"); + assert_eq!(nullifiers[1]["state"], "failed"); + // txid is internal byte order — encode_hex of kernel bytes, never reversed. + let expected_txid: Vec = (1u8..=32).collect(); + assert_eq!(json["txid"].as_str().unwrap(), encode_hex(&expected_txid)); + } + + #[test] + fn confirmation_state_failed_is_rejected() { + let ins = sample_inscription(1, 0, 0, "failed", vec![sample_nullifier("pending")]); + let err = inscription_to_json(&ins).expect_err("failed confirmation"); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, crate::error::PUBLIC_INTERNAL_MESSAGE); + assert!( + err.cause().unwrap_or("").contains("confirmation_state"), + "operator cause must name confirmation_state, got {:?}", + err.cause() + ); + } + + #[test] + fn next_cursor_all_or_nothing_in_json() { + let page_with = InscriptionsPage { + inscriptions: vec![sample_inscription( + 1, + 0, + 0, + "pending", + vec![sample_nullifier("pending")], + )], + next: Some(TripleCursor { + height: 1, + tx_index: 0, + vin_index: 1, + }), + }; + let json = inscriptions_page_to_json(&page_with).expect("json"); + assert_eq!(json["next_height"], 1); + assert_eq!(json["next_tx_index"], 0); + assert_eq!(json["next_vin_index"], 1); + + let page_without = InscriptionsPage { + inscriptions: vec![], + next: None, + }; + let json = inscriptions_page_to_json(&page_without).expect("json"); + assert!(json.get("next_height").is_none()); + assert!(json.get("next_tx_index").is_none()); + assert!(json.get("next_vin_index").is_none()); + assert_eq!(json["inscriptions"], json!([])); + } + + #[test] + fn limit_zero_is_bounds_exceeded_not_malformed() { + let err = parse_list_inscriptions_query(Some("limit=0")).expect_err("limit=0"); + assert_eq!(err.body.error, "bounds_exceeded"); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + } + + #[test] + fn limit_over_1000_is_bounds_exceeded() { + let err = parse_list_inscriptions_query(Some("limit=1001")).expect_err("limit=1001"); + assert_eq!(err.body.error, "bounds_exceeded"); + } + + #[test] + fn limit_non_numeric_is_malformed() { + let err = parse_list_inscriptions_query(Some("limit=abc")).expect_err("limit=abc"); + assert_eq!(err.body.error, "malformed_request"); + assert!( + err.body.message.contains("limit"), + "message must name limit, got {}", + err.body.message + ); + } + + #[test] + fn omitted_query_normalises_to_defaults() { + let q = parse_list_inscriptions_query(None).expect("defaults"); + assert_eq!(q.from_height, 0); + assert_eq!(q.from_tx_index, 0); + assert_eq!(q.from_vin_index, 0); + assert_eq!(q.limit, 100); + } + + #[test] + fn explicit_zero_cursors_are_not_replaced() { + let q = parse_list_inscriptions_query(Some( + "from_height=0&from_tx_index=0&from_vin_index=0&limit=1", + )) + .expect("zeros"); + assert_eq!(q.from_height, 0); + assert_eq!(q.from_tx_index, 0); + assert_eq!(q.from_vin_index, 0); + assert_eq!(q.limit, 1); + } + + // ----------------------------------------------------------------------- + // Page-boundary pagination against a catalog double + // ----------------------------------------------------------------------- + + struct CatalogKernel { + catalog: Vec, + } + + fn filter_catalog(catalog: &[Inscription], req: &ListInscriptionsRequest) -> Vec { + // Kernel-side double: same §7.5 defaults the API normalises before RPC + // (proto comment on ListInscriptionsRequest). + let from_h = req.from_height.unwrap_or(DEFAULT_FROM_HEIGHT); + let from_t = req.from_tx_index.unwrap_or(DEFAULT_FROM_TX_INDEX); + let from_v = req.from_vin_index.unwrap_or(DEFAULT_FROM_VIN_INDEX); + let limit = req.limit.unwrap_or(DEFAULT_LIMIT) as usize; + catalog + .iter() + .filter(|ins| (ins.height, ins.tx_index, ins.vin_index) >= (from_h, from_t, from_v)) + .take(limit) + .cloned() + .collect() + } + + #[async_trait] + impl KernelRpc for CatalogKernel { + async fn submit_transition( + &self, + _req: crate::kernel::kernel_v1::TransitionRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn get_job( + &self, + _req: crate::kernel::kernel_v1::JobRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn stream_job( + &self, + _req: crate::kernel::kernel_v1::JobRequest, + ) -> Result< + BoxStream<'static, Result>, + ApiError, + > { + Err(ApiError::internal("not used")) + } + async fn sign_transition( + &self, + _req: crate::kernel::kernel_v1::SignRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn cancel_job( + &self, + _req: crate::kernel::kernel_v1::JobRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn get_info(&self) -> Result { + Err(ApiError::internal("not used")) + } + async fn get_accumulator(&self) -> Result { + Err(ApiError::internal("not used")) + } + async fn list_inscriptions( + &self, + req: ListInscriptionsRequest, + ) -> Result>, ApiError> { + let items = filter_catalog(&self.catalog, &req); + Ok(Box::pin(stream::iter(items.into_iter().map(Ok)))) + } + async fn get_nullifier_path( + &self, + _req: NullifierPathRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn open_pull_challenge( + &self, + _req: crate::kernel::kernel_v1::PullChallengeRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn attest_balance( + &self, + _req: crate::kernel::kernel_v1::AttestRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn issue_view_grant( + &self, + _req: crate::kernel::kernel_v1::GrantRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn pull( + &self, + _req: crate::kernel::kernel_v1::PullRequest, + _authority: crate::ownership::SessionAuthority, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn get_record( + &self, + _req: crate::kernel::kernel_v1::RecordRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn get_coin_proof( + &self, + _req: crate::kernel::kernel_v1::CoinProofRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn get_account_state( + &self, + _req: crate::kernel::kernel_v1::AccountStateRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn subscribe_receipts( + &self, + _req: crate::kernel::kernel_v1::SubscribeReceiptsRequest, + ) -> Result>, ApiError> + { + Err(ApiError::internal("not used")) + } + async fn entrust_operational_bundle( + &self, + _req: crate::kernel::kernel_v1::EntrustRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn revoke_operational_bundle( + &self, + _req: crate::kernel::kernel_v1::RevokeRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + async fn publish( + &self, + _req: crate::kernel::kernel_v1::PublishRequest, + ) -> Result { + Err(ApiError::internal("not used")) + } + } + + /// Three pages with limit=1; page boundary sits mid-reveal-tx (vin 0/1/2 + /// of the same (height, tx_index)). Exclusive next of page n is inclusive + /// from of page n+1 — no duplicates, no gaps. + #[tokio::test] + async fn multi_page_cursor_splits_mid_reveal_tx() { + let catalog = vec![ + sample_inscription(10, 0, 0, "completed", vec![sample_nullifier("completed")]), + sample_inscription(10, 0, 1, "completed", vec![sample_nullifier("completed")]), + sample_inscription(10, 0, 2, "completed", vec![sample_nullifier("pending")]), + sample_inscription(10, 1, 0, "pending", vec![sample_nullifier("pending")]), + ]; + let kernel: KernelHandle = Arc::new(CatalogKernel { catalog }); + + // Page 1 + let page1 = fetch_inscriptions_page( + &kernel, + ListInscriptionsQuery { + from_height: 0, + from_tx_index: 0, + from_vin_index: 0, + limit: 1, + }, + ) + .await + .expect("page1"); + assert_eq!(page1.inscriptions.len(), 1); + assert_eq!(page1.inscriptions[0].vin_index, 0); + let next1 = page1.next.expect("page1 must have next"); + assert_eq!( + (next1.height, next1.tx_index, next1.vin_index), + (10, 0, 1), + "exclusive next after first vin of the multi-vin reveal" + ); + + // Page 2 — exclusive next of page1 is inclusive from here + let page2 = fetch_inscriptions_page( + &kernel, + ListInscriptionsQuery { + from_height: next1.height, + from_tx_index: next1.tx_index, + from_vin_index: next1.vin_index, + limit: 1, + }, + ) + .await + .expect("page2"); + assert_eq!(page2.inscriptions.len(), 1); + assert_eq!(page2.inscriptions[0].vin_index, 1); + assert_eq!( + page2.inscriptions[0].height, page1.inscriptions[0].height, + "same reveal height" + ); + assert_eq!( + page2.inscriptions[0].tx_index, page1.inscriptions[0].tx_index, + "same reveal tx_index — boundary is mid-transaction" + ); + let next2 = page2.next.expect("page2 must have next"); + assert_eq!((next2.height, next2.tx_index, next2.vin_index), (10, 0, 2)); + + // Page 3 + let page3 = fetch_inscriptions_page( + &kernel, + ListInscriptionsQuery { + from_height: next2.height, + from_tx_index: next2.tx_index, + from_vin_index: next2.vin_index, + limit: 1, + }, + ) + .await + .expect("page3"); + assert_eq!(page3.inscriptions.len(), 1); + assert_eq!(page3.inscriptions[0].vin_index, 2); + let next3 = page3 + .next + .expect("page3 must have next (fourth item remains)"); + assert_eq!((next3.height, next3.tx_index, next3.vin_index), (10, 1, 0)); + + // Collect all via the three page starts + final remainder — no dups/gaps. + let mut seen = vec![ + ( + page1.inscriptions[0].height, + page1.inscriptions[0].tx_index, + page1.inscriptions[0].vin_index, + ), + ( + page2.inscriptions[0].height, + page2.inscriptions[0].tx_index, + page2.inscriptions[0].vin_index, + ), + ( + page3.inscriptions[0].height, + page3.inscriptions[0].tx_index, + page3.inscriptions[0].vin_index, + ), + ]; + let page4 = fetch_inscriptions_page( + &kernel, + ListInscriptionsQuery { + from_height: next3.height, + from_tx_index: next3.tx_index, + from_vin_index: next3.vin_index, + limit: 1, + }, + ) + .await + .expect("page4"); + assert_eq!(page4.inscriptions.len(), 1); + assert!(page4.next.is_none(), "final page must omit all next_*"); + seen.push(( + page4.inscriptions[0].height, + page4.inscriptions[0].tx_index, + page4.inscriptions[0].vin_index, + )); + assert_eq!( + seen, + vec![(10, 0, 0), (10, 0, 1), (10, 0, 2), (10, 1, 0)], + "contiguous coverage across mid-tx page boundary" + ); + } + + #[tokio::test] + async fn empty_catalog_is_empty_list_not_404() { + let kernel: KernelHandle = Arc::new(CatalogKernel { + catalog: Vec::new(), + }); + let page = fetch_inscriptions_page( + &kernel, + ListInscriptionsQuery { + from_height: 0, + from_tx_index: 0, + from_vin_index: 0, + limit: 100, + }, + ) + .await + .expect("empty"); + assert!(page.inscriptions.is_empty()); + assert!(page.next.is_none()); + let json = inscriptions_page_to_json(&page).expect("json"); + assert_eq!(json["inscriptions"], json!([])); + assert!(json.get("next_height").is_none()); + } + + /// §7.8 promises stable triple order; an out-of-order stream is + /// `internal_error`, not a silently re-sorted page. + #[tokio::test] + async fn out_of_order_kernel_stream_is_internal_error() { + let catalog = vec![ + sample_inscription(10, 0, 1, "completed", vec![sample_nullifier("completed")]), + sample_inscription(10, 0, 0, "completed", vec![sample_nullifier("completed")]), + ]; + let kernel: KernelHandle = Arc::new(CatalogKernel { catalog }); + let err = fetch_inscriptions_page( + &kernel, + ListInscriptionsQuery { + from_height: 0, + from_tx_index: 0, + from_vin_index: 0, + limit: 10, + }, + ) + .await + .expect_err("reversed triples must fail closed"); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, crate::error::PUBLIC_INTERNAL_MESSAGE); + let cause = err.cause().unwrap_or(""); + assert!( + cause.contains("strictly increasing") + && cause.contains("height") + && cause.contains("tx_index") + && cause.contains("vin_index"), + "operator cause must name the triple-order contract, got {cause}" + ); + } + + #[test] + fn strict_triple_order_accepts_increasing_and_rejects_equal() { + require_strict_triple_order(&[]).expect("empty"); + require_strict_triple_order(&[sample_inscription( + 1, + 0, + 0, + "pending", + vec![sample_nullifier("pending")], + )]) + .expect("singleton"); + let ok = vec![ + sample_inscription(1, 0, 0, "pending", vec![sample_nullifier("pending")]), + sample_inscription(1, 0, 1, "pending", vec![sample_nullifier("pending")]), + ]; + require_strict_triple_order(&ok).expect("increasing"); + let dup = vec![ + sample_inscription(1, 0, 0, "pending", vec![sample_nullifier("pending")]), + sample_inscription(1, 0, 0, "pending", vec![sample_nullifier("pending")]), + ]; + let err = require_strict_triple_order(&dup).expect_err("duplicate triple"); + assert_eq!(err.body.error, "internal_error"); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..69b60fa --- /dev/null +++ b/src/config.rs @@ -0,0 +1,638 @@ +//! Fail-closed process configuration from the environment. +//! +//! Required variables (no defaults, no host/port fallbacks): +//! - `ZKCOINS_BIND_ADDR` — HTTP listen address (`host:port`) +//! - `ZKCOINS_KERNEL_ADDR` — kernel gRPC address (opaque non-empty string) +//! - `ZKCOINS_FEATURES` — comma-separated subset of the §6.1 closed feature set +//! (may be empty string = all features off; unknown token is a start error) +//! - `ZKCOINS_PUBLIC_HOST` — comma-separated authoritative hostnames for +//! §5.1 `chan_bind` (may be empty string; empty ⇒ OwnershipProof auth fails +//! loud with no silent localhost). Never taken from a `Host` header. +//! +//! Optional Blossom surface (§7.4) — all-or-nothing: +//! - `ZKCOINS_BLOSSOM_STORE` — filesystem root for the content-addressed store. +//! **Absent** ⇒ Blossom routes are not mounted and the four discovery keys +//! are not advertised. **No default path**, no `/tmp` fallback. +//! - When the store is set, these companions are required (fail-closed boot): +//! - `ZKCOINS_BLOSSOM_MAX_BLOB_BYTES` — advertised upload size limit (`> 0`) +//! - `ZKCOINS_BLOSSOM_ALLOWED_OPS` — comma-separated lowercase-hex 32-byte +//! `op` pubkeys allowed to upload (paired accounts + replication peers; +//! may be empty ⇒ every upload is `403`) + +use std::collections::BTreeSet; +use std::env; +use std::fmt; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::str::FromStr; + +/// Closed API feature set from specification §6.1. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Feature { + Wallet, + Explorer, + Publisher, + LightningBridge, + MailBridge, +} + +impl Feature { + pub const ALL: [Feature; 5] = [ + Feature::Wallet, + Feature::Explorer, + Feature::Publisher, + Feature::LightningBridge, + Feature::MailBridge, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Feature::Wallet => "wallet", + Feature::Explorer => "explorer", + Feature::Publisher => "publisher", + Feature::LightningBridge => "lightning_bridge", + Feature::MailBridge => "mail_bridge", + } + } +} + +impl FromStr for Feature { + type Err = ConfigError; + + fn from_str(s: &str) -> Result { + match s { + "wallet" => Ok(Feature::Wallet), + "explorer" => Ok(Feature::Explorer), + "publisher" => Ok(Feature::Publisher), + "lightning_bridge" => Ok(Feature::LightningBridge), + "mail_bridge" => Ok(Feature::MailBridge), + other => Err(ConfigError::UnknownFeature(other.to_string())), + } + } +} + +/// Optional §7.4 Blossom store configuration. +/// +/// Present only when `ZKCOINS_BLOSSOM_STORE` is set. Absence means the four +/// Blossom discovery keys stay unadvertised and the routes stay unmounted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlossomConfig { + /// Content-addressed store root on the local filesystem. + pub store_root: PathBuf, + /// Advertised maximum upload body size in bytes (`> 0`). + pub max_blob_bytes: u64, + /// `op` pubkeys (32 raw bytes) allowed to PUT/POST — paired accounts and + /// configured replication peers. Empty set ⇒ every upload is `403`. + pub allowed_upload_ops: BTreeSet<[u8; 32]>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Config { + /// HTTP bind address. Parsed as `SocketAddr` so empty/garbage fails loudly. + pub bind_addr: SocketAddr, + /// Kernel gRPC target. Stored as configured; this scaffold does not dial it. + pub kernel_addr: String, + /// Enabled API features (§6.1 closed set). Empty = all off. + pub features: BTreeSet, + /// Authoritative public hostnames for §5.1 `chan_bind` (canonical form). + /// Derived only from `ZKCOINS_PUBLIC_HOST` — never from request headers. + pub public_hosts: Vec, + /// §7.4 Blossom surface. `None` when `ZKCOINS_BLOSSOM_STORE` is unset. + pub blossom: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + MissingEnv(&'static str), + EmptyEnv(&'static str), + InvalidBindAddr { value: String, reason: String }, + UnknownFeature(String), + InvalidBlossomMaxBlobBytes { value: String, reason: String }, + InvalidBlossomAllowedOp { value: String, reason: String }, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigError::MissingEnv(name) => { + write!(f, "required environment variable {name} is not set") + } + ConfigError::EmptyEnv(name) => { + write!(f, "required environment variable {name} is set but empty") + } + ConfigError::InvalidBindAddr { value, reason } => { + write!( + f, + "ZKCOINS_BIND_ADDR value {value:?} is not a valid socket address: {reason}" + ) + } + ConfigError::UnknownFeature(name) => { + write!( + f, + "unknown feature {name:?}; allowed values are wallet, explorer, publisher, lightning_bridge, mail_bridge" + ) + } + ConfigError::InvalidBlossomMaxBlobBytes { value, reason } => { + write!( + f, + "ZKCOINS_BLOSSOM_MAX_BLOB_BYTES value {value:?} is invalid: {reason}" + ) + } + ConfigError::InvalidBlossomAllowedOp { value, reason } => { + write!( + f, + "ZKCOINS_BLOSSOM_ALLOWED_OPS entry {value:?} is invalid: {reason}" + ) + } + } + } +} + +impl std::error::Error for ConfigError {} + +const ENV_BIND: &str = "ZKCOINS_BIND_ADDR"; +const ENV_KERNEL: &str = "ZKCOINS_KERNEL_ADDR"; +const ENV_FEATURES: &str = "ZKCOINS_FEATURES"; +const ENV_PUBLIC_HOST: &str = "ZKCOINS_PUBLIC_HOST"; +/// Optional gate for the §7.4 Blossom surface. Absent ⇒ not advertised. +const ENV_BLOSSOM_STORE: &str = "ZKCOINS_BLOSSOM_STORE"; +/// Required companion when `ZKCOINS_BLOSSOM_STORE` is set. +const ENV_BLOSSOM_MAX_BLOB_BYTES: &str = "ZKCOINS_BLOSSOM_MAX_BLOB_BYTES"; +/// Required companion when `ZKCOINS_BLOSSOM_STORE` is set (may be empty). +const ENV_BLOSSOM_ALLOWED_OPS: &str = "ZKCOINS_BLOSSOM_ALLOWED_OPS"; + +impl Config { + /// Load configuration from process environment. Fail-closed: every required + /// variable must be present; bind/kernel must be non-empty; features must + /// be a (possibly empty) subset of the closed set. + pub fn from_env() -> Result { + Self::from_getter(|key| env::var(key).ok()) + } + + /// Testable entry: same rules as `from_env`, driven by an arbitrary getter. + /// A missing key is `None`; present-but-empty is `Some("")`. + pub fn from_getter(mut get: F) -> Result + where + F: FnMut(&str) -> Option, + { + let bind_raw = require_present(&mut get, ENV_BIND)?; + let kernel_raw = require_present(&mut get, ENV_KERNEL)?; + let features_raw = require_present(&mut get, ENV_FEATURES)?; + let public_host_raw = require_present(&mut get, ENV_PUBLIC_HOST)?; + + if bind_raw.is_empty() { + return Err(ConfigError::EmptyEnv(ENV_BIND)); + } + if kernel_raw.is_empty() { + return Err(ConfigError::EmptyEnv(ENV_KERNEL)); + } + // FEATURES and PUBLIC_HOST may be empty. They must still be *set*. + // Empty PUBLIC_HOST ⇒ no authoritative chan_bind (auth fails loud). + + let bind_addr = + bind_raw + .parse::() + .map_err(|e| ConfigError::InvalidBindAddr { + value: bind_raw.clone(), + reason: e.to_string(), + })?; + + let features = parse_features(&features_raw)?; + let public_hosts = parse_public_hosts(&public_host_raw); + let blossom = parse_blossom_config(&mut get)?; + + Ok(Config { + bind_addr, + kernel_addr: kernel_raw, + features, + public_hosts, + blossom, + }) + } +} + +fn require_present(get: &mut F, key: &'static str) -> Result +where + F: FnMut(&str) -> Option, +{ + match get(key) { + None => Err(ConfigError::MissingEnv(key)), + Some(v) => Ok(v), + } +} + +fn parse_features(raw: &str) -> Result, ConfigError> { + let mut out = BTreeSet::new(); + for part in raw.split(',') { + let token = part.trim(); + if token.is_empty() { + continue; + } + out.insert(Feature::from_str(token)?); + } + Ok(out) +} + +/// Canonicalise authoritative hosts for `chan_bind` (§5.1): lowercase ASCII, +/// trailing dot stripped. Empty tokens dropped. No localhost default. +fn parse_public_hosts(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim().trim_end_matches('.').to_ascii_lowercase()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Optional Blossom surface. `None` only when `ZKCOINS_BLOSSOM_STORE` is +/// **unset**. Present-but-empty store is an error (no silent `/tmp` default). +/// When the store is set, max-blob and allowed-ops companions are required. +fn parse_blossom_config(get: &mut F) -> Result, ConfigError> +where + F: FnMut(&str) -> Option, +{ + let store_raw = match get(ENV_BLOSSOM_STORE) { + None => return Ok(None), + Some(v) => v, + }; + if store_raw.is_empty() { + return Err(ConfigError::EmptyEnv(ENV_BLOSSOM_STORE)); + } + + let max_raw = require_present(get, ENV_BLOSSOM_MAX_BLOB_BYTES)?; + if max_raw.is_empty() { + return Err(ConfigError::EmptyEnv(ENV_BLOSSOM_MAX_BLOB_BYTES)); + } + let max_blob_bytes = parse_max_blob_bytes(&max_raw)?; + + let ops_raw = require_present(get, ENV_BLOSSOM_ALLOWED_OPS)?; + // Empty string is allowed: surface is up, but every upload is 403. + let allowed_upload_ops = parse_allowed_ops(&ops_raw)?; + + Ok(Some(BlossomConfig { + store_root: PathBuf::from(store_raw), + max_blob_bytes, + allowed_upload_ops, + })) +} + +fn parse_max_blob_bytes(raw: &str) -> Result { + // Strict decimal u64, no leading zeros except "0" itself — but 0 is + // invalid (limit must be > 0). No clamping, no silent default. + if raw == "0" { + return Err(ConfigError::InvalidBlossomMaxBlobBytes { + value: raw.to_string(), + reason: "must be strictly greater than zero".to_string(), + }); + } + if raw.is_empty() || raw.as_bytes()[0] == b'0' { + return Err(ConfigError::InvalidBlossomMaxBlobBytes { + value: raw.to_string(), + reason: "must be a canonical decimal u64 with no leading zeros".to_string(), + }); + } + if !raw.bytes().all(|b| b.is_ascii_digit()) { + return Err(ConfigError::InvalidBlossomMaxBlobBytes { + value: raw.to_string(), + reason: "must contain only ASCII digits".to_string(), + }); + } + raw.parse::() + .map_err(|_| ConfigError::InvalidBlossomMaxBlobBytes { + value: raw.to_string(), + reason: "out of u64 range".to_string(), + }) +} + +fn parse_allowed_ops(raw: &str) -> Result, ConfigError> { + let mut out = BTreeSet::new(); + for part in raw.split(',') { + let token = part.trim(); + if token.is_empty() { + continue; + } + // Lowercase hex only — uppercase is rejected (no silent fold). + if token.len() != 64 { + return Err(ConfigError::InvalidBlossomAllowedOp { + value: token.to_string(), + reason: format!( + "must be exactly 64 lowercase hex characters, got {}", + token.len() + ), + }); + } + if !token + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + { + return Err(ConfigError::InvalidBlossomAllowedOp { + value: token.to_string(), + reason: "must be lowercase hex [0-9a-f] only".to_string(), + }); + } + let mut key = [0u8; 32]; + for (i, chunk) in token.as_bytes().chunks(2).enumerate() { + let hi = hex_nibble(chunk[0]); + let lo = hex_nibble(chunk[1]); + key[i] = (hi << 4) | lo; + } + out.insert(key); + } + Ok(out) +} + +fn hex_nibble(b: u8) -> u8 { + match b { + b'0'..=b'9' => b - b'0', + b'a'..=b'f' => b - b'a' + 10, + _ => unreachable!("caller validated lowercase hex"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn getter(map: HashMap<&'static str, &'static str>) -> impl FnMut(&str) -> Option { + move |k| map.get(k).map(|s| (*s).to_string()) + } + + #[test] + fn accepts_valid_minimal_config() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + ])); + let cfg = Config::from_getter(&mut get).expect("valid config"); + assert_eq!(cfg.bind_addr, "127.0.0.1:8080".parse().unwrap()); + assert_eq!(cfg.kernel_addr, "http://127.0.0.1:50051"); + assert!(cfg.features.is_empty()); + assert!(cfg.public_hosts.is_empty()); + assert!( + cfg.blossom.is_none(), + "unset ZKCOINS_BLOSSOM_STORE must leave blossom unconfigured" + ); + } + + #[test] + fn blossom_store_absent_is_not_configured() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + ])); + let cfg = Config::from_getter(&mut get).expect("valid config"); + assert!(cfg.blossom.is_none()); + } + + #[test] + fn blossom_store_empty_is_error_not_default() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + (ENV_BLOSSOM_STORE, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("empty store"); + assert_eq!(err, ConfigError::EmptyEnv(ENV_BLOSSOM_STORE)); + } + + #[test] + fn blossom_store_requires_companions() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + (ENV_BLOSSOM_STORE, "/var/lib/zkcoins/blossom"), + ])); + let err = Config::from_getter(&mut get).expect_err("missing max"); + assert_eq!(err, ConfigError::MissingEnv(ENV_BLOSSOM_MAX_BLOB_BYTES)); + } + + #[test] + fn blossom_store_configured_with_companions() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + (ENV_BLOSSOM_STORE, "/var/lib/zkcoins/blossom"), + (ENV_BLOSSOM_MAX_BLOB_BYTES, "1048576"), + ( + ENV_BLOSSOM_ALLOWED_OPS, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + ])); + let cfg = Config::from_getter(&mut get).expect("valid blossom"); + let blossom = cfg.blossom.expect("configured"); + assert_eq!( + blossom.store_root, + PathBuf::from("/var/lib/zkcoins/blossom") + ); + assert_eq!(blossom.max_blob_bytes, 1_048_576); + assert_eq!(blossom.allowed_upload_ops.len(), 1); + } + + #[test] + fn blossom_max_blob_zero_is_error() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + (ENV_BLOSSOM_STORE, "/var/lib/zkcoins/blossom"), + (ENV_BLOSSOM_MAX_BLOB_BYTES, "0"), + (ENV_BLOSSOM_ALLOWED_OPS, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("zero max"); + match err { + ConfigError::InvalidBlossomMaxBlobBytes { value, .. } => { + assert_eq!(value, "0"); + } + other => panic!("expected InvalidBlossomMaxBlobBytes, got {other:?}"), + } + } + + #[test] + fn accepts_known_features() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "[::1]:9"), + (ENV_KERNEL, "http://kernel:50051"), + (ENV_FEATURES, "wallet, explorer,publisher"), + (ENV_PUBLIC_HOST, "api.example.com"), + ])); + let cfg = Config::from_getter(&mut get).expect("valid config"); + assert_eq!( + cfg.features, + BTreeSet::from([Feature::Wallet, Feature::Explorer, Feature::Publisher]) + ); + assert_eq!(cfg.public_hosts, vec!["api.example.com".to_string()]); + } + + #[test] + fn public_hosts_are_canonicalised() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, "API.Example.COM., other.EXAMPLE.com"), + ])); + let cfg = Config::from_getter(&mut get).expect("valid config"); + assert_eq!( + cfg.public_hosts, + vec![ + "api.example.com".to_string(), + "other.example.com".to_string() + ] + ); + } + + #[test] + fn unknown_feature_is_start_error_with_name() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, "wallet,not_a_feature"), + (ENV_PUBLIC_HOST, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("unknown feature"); + match &err { + ConfigError::UnknownFeature(name) => assert_eq!(name, "not_a_feature"), + other => panic!("expected UnknownFeature, got {other:?}"), + } + // Display names the bad token and the closed set. + let msg = err.to_string(); + assert!( + msg.contains("not_a_feature"), + "display must name the unknown feature: {msg}" + ); + assert!( + msg.contains("wallet") && msg.contains("mail_bridge"), + "display must list allowed features: {msg}" + ); + } + + #[test] + fn missing_bind_addr_is_named() { + let mut get = getter(HashMap::from([ + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("missing bind"); + assert_eq!(err, ConfigError::MissingEnv(ENV_BIND)); + assert!( + err.to_string().contains(ENV_BIND), + "error must name the missing variable: {err}" + ); + } + + #[test] + fn missing_kernel_addr_is_named() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("missing kernel"); + assert_eq!(err, ConfigError::MissingEnv(ENV_KERNEL)); + assert!(err.to_string().contains(ENV_KERNEL)); + } + + #[test] + fn missing_features_var_is_named() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_PUBLIC_HOST, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("missing features"); + assert_eq!(err, ConfigError::MissingEnv(ENV_FEATURES)); + assert!(err.to_string().contains(ENV_FEATURES)); + } + + #[test] + fn missing_public_host_var_is_named() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("missing public host"); + assert_eq!(err, ConfigError::MissingEnv(ENV_PUBLIC_HOST)); + assert!(err.to_string().contains(ENV_PUBLIC_HOST)); + } + + #[test] + fn empty_bind_addr_is_empty_env_error() { + let mut get = getter(HashMap::from([ + (ENV_BIND, ""), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("empty bind"); + assert_eq!(err, ConfigError::EmptyEnv(ENV_BIND)); + } + + #[test] + fn empty_kernel_addr_is_empty_env_error() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, ""), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("empty kernel"); + assert_eq!(err, ConfigError::EmptyEnv(ENV_KERNEL)); + } + + #[test] + fn invalid_bind_addr_reports_value_and_reason() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "not-a-socket"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("bad bind"); + match &err { + ConfigError::InvalidBindAddr { value, reason } => { + assert_eq!(value, "not-a-socket"); + assert!(!reason.is_empty(), "parse reason must be non-empty"); + } + other => panic!("expected InvalidBindAddr, got {other:?}"), + } + } + + #[test] + fn no_default_localhost_when_bind_missing() { + // Explicit: absence is an error, never 127.0.0.1 / :0 / etc. + let mut get = getter(HashMap::from([ + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, "wallet"), + (ENV_PUBLIC_HOST, ""), + ])); + let err = Config::from_getter(&mut get).expect_err("no default bind"); + assert!(matches!(err, ConfigError::MissingEnv(ENV_BIND))); + } + + #[test] + fn no_default_localhost_for_public_host() { + let mut get = getter(HashMap::from([ + (ENV_BIND, "127.0.0.1:8080"), + (ENV_KERNEL, "http://127.0.0.1:50051"), + (ENV_FEATURES, ""), + (ENV_PUBLIC_HOST, ""), + ])); + let cfg = Config::from_getter(&mut get).expect("empty public host is allowed"); + assert!( + cfg.public_hosts.is_empty(), + "empty PUBLIC_HOST must not invent localhost" + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..77a862d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,154 @@ +//! §7.5 generic REST error body and HTTP mapping helpers. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; + +/// Closed §7.5 error body: `{ "error": "", "message": "" }`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ErrorBody { + pub error: String, + pub message: String, +} + +/// Public wire text for every `500 internal_error`. Internal diagnostics stay +/// off the wire (absolute paths, OS errors, kernel contract detail) and are +/// carried only in [`ApiError::cause`] / structured logs. +pub const PUBLIC_INTERNAL_MESSAGE: &str = "an internal error occurred"; + +/// An HTTP error ready to return from a handler. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiError { + pub status: StatusCode, + pub body: ErrorBody, + /// Operator-facing cause for logs / startup diagnostics. **Never** copied + /// into the HTTP body by [`IntoResponse`]. + pub(crate) cause: Option, +} + +impl ApiError { + pub fn new(status: StatusCode, error: impl Into, message: impl Into) -> Self { + Self { + status, + body: ErrorBody { + error: error.into(), + message: message.into(), + }, + cause: None, + } + } + + /// §7.5 `malformed_request` / 400. + pub fn malformed(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, "malformed_request", message) + } + + /// §7.5 `bounds_exceeded` / 400 — numeric but outside the closed range + /// (e.g. `limit` ∉ `1..=1000`). Distinct from `malformed_request`, which + /// covers non-numeric / overflowing query values. + pub fn bounds_exceeded(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, "bounds_exceeded", message) + } + + /// §7.5 `unauthorized` / 401 — API-edge OwnershipProof / capability failures + /// (wrong domain, bad signature, GrantProof, address mismatch, chan_bind). + /// + /// Spec §7.5 L2894 / L2896: missing/invalid/wrong-domain OwnershipProof or + /// GrantProof → `401 unauthorized`. Generated by the API itself; not from + /// kernel `ErrorInfo.metadata["http_status"]`. Also Blossom kind-24242 + /// auth-event rejection (§7.4). + pub fn unauthorized(message: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message) + } + + /// §7.5 `scope_exceeded` / 403 — non-peer Blossom upload, resolved-scope + /// violation. + pub fn scope_exceeded(message: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, "scope_exceeded", message) + } + + /// §7.5 `not_found` / 404 — unknown `blob_id` (and similar). + pub fn not_found(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, "not_found", message) + } + + /// §7.5 intro / §6.1: known route whose role feature is off for this + /// deployment → `404 feature_disabled`. Distinct from a bare axum 404 for + /// a path that was never registered (including unconfigured Blossom). + pub fn feature_disabled(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, "feature_disabled", message) + } + + /// §7.5 `payload_too_large` / 413 — Blossom body over the advertised limit. + pub fn payload_too_large(message: impl Into) -> Self { + Self::new(StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large", message) + } + + /// Fail-closed stand-in when the kernel transport breaks or the kernel + /// violates the ErrorInfo contract. Spec §7.5 closes the enumeration with + /// `internal_error` / 500 for any condition not listed. + /// + /// The public `message` is always [`PUBLIC_INTERNAL_MESSAGE`]. The + /// diagnostic string is stored in [`Self::cause`] and emitted via + /// `tracing` only — never forwarded onto the wire. + pub fn internal(cause: impl Into) -> Self { + let cause = cause.into(); + tracing::error!(cause = %cause, "internal_error"); + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + body: ErrorBody { + error: "internal_error".to_string(), + message: PUBLIC_INTERNAL_MESSAGE.to_string(), + }, + cause: Some(cause), + } + } + + /// Operator-facing cause when present (startup / tests). Not the wire body. + pub fn cause(&self) -> Option<&str> { + self.cause.as_deref() + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + // Always a §7.5 JSON body — never a bare status with an empty body. + // (Axum's default 404 fallback is status-only; handlers must not + // look like that when they intentionally return an ApiError.) + // `cause` is intentionally dropped here. + (self.status, Json(self.body)).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + + #[tokio::test] + async fn internal_public_body_is_neutral_cause_stays_off_wire() { + let err = ApiError::internal( + "blossom store: cannot create root /var/lib/secret-path: permission denied", + ); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, PUBLIC_INTERNAL_MESSAGE); + assert!( + err.cause().expect("cause retained").contains("secret-path"), + "operator cause must retain the diagnostic" + ); + let res = err.clone().into_response(); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let text = String::from_utf8(bytes.to_vec()).unwrap(); + assert!( + !text.contains("secret-path"), + "public body must not leak the path: {text}" + ); + assert!( + !text.contains("permission denied"), + "public body must not leak the OS error: {text}" + ); + assert!(text.contains(PUBLIC_INTERNAL_MESSAGE)); + } +} diff --git a/src/extract.rs b/src/extract.rs new file mode 100644 index 0000000..f0125f9 --- /dev/null +++ b/src/extract.rs @@ -0,0 +1,153 @@ +//! Shared request extractors that map axum rejections into §7.5 `ApiError`. +//! +//! Axum's default `Json` / `Bytes` rejections answer with framework status +//! codes and non-§7.5 bodies (422 unprocessable, plain-text 413, …). Every +//! public handler that reads a JSON or limited raw body must go through these +//! extractors so clients always see the closed machine-code form. + +use crate::error::ApiError; +use async_trait::async_trait; +use axum::body::Bytes; +use axum::extract::rejection::{BytesRejection, JsonRejection}; +use axum::extract::{FromRequest, Request}; +use axum::http::StatusCode; +use axum::Json; +use serde::de::DeserializeOwned; + +/// JSON body extractor that translates every rejection into §7.5 JSON. +/// +/// Use in place of `axum::Json` on public handlers. +#[derive(Debug)] +pub struct JsonBody(pub T); + +// axum 0.7 / axum-core 0.4: `FromRequest` is an `#[async_trait]` trait — the +// impl must carry the same attribute so the lifetime/Send desugaring matches +// the trait declaration (otherwise E0195 and handlers never see the extractor). +#[async_trait] +impl FromRequest for JsonBody +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, state: &S) -> Result { + match Json::::from_request(req, state).await { + Ok(Json(value)) => Ok(JsonBody(value)), + Err(rejection) => Err(json_rejection_to_api_error(rejection)), + } + } +} + +/// Map an axum [`JsonRejection`] onto the closed §7.5 error surface. +/// +/// - Missing / wrong Content-Type → `400 malformed_request` +/// - Syntax / data errors → `400 malformed_request` +/// - Body length limit (DefaultBodyLimit) → `413 payload_too_large` +pub fn json_rejection_to_api_error(rejection: JsonRejection) -> ApiError { + match rejection { + JsonRejection::MissingJsonContentType(_) => { + ApiError::malformed("Content-Type must be application/json") + } + JsonRejection::JsonDataError(err) => ApiError::malformed(format!("request body: {err}")), + JsonRejection::JsonSyntaxError(err) => ApiError::malformed(format!("request body: {err}")), + JsonRejection::BytesRejection(err) => bytes_rejection_to_api_error(err), + other => ApiError::malformed(format!("request body: {other}")), + } +} + +/// Raw-body extractor with the same §7.5 rejection mapping as [`JsonBody`]. +/// +/// Used by Blossom upload so oversize bodies (including those far above the +/// configured max, not only `max + 1`) still answer with +/// `413 payload_too_large` and a JSON body — not axum's plain-text 413. +pub struct LimitedBytes(pub Bytes); + +#[async_trait] +impl FromRequest for LimitedBytes +where + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request(req: Request, state: &S) -> Result { + match Bytes::from_request(req, state).await { + Ok(bytes) => Ok(LimitedBytes(bytes)), + Err(rejection) => Err(bytes_rejection_to_api_error(rejection)), + } + } +} + +/// Map an axum [`BytesRejection`] (body buffer / length limit) to §7.5. +/// +/// axum 0.7 encodes both length-limit and unknown buffer failures under +/// `FailedToBufferBody` with the **same** Display body +/// (`"Failed to buffer the request body"`). The stable discriminator is +/// [`BytesRejection::status`]: `LengthLimitError` is 413, other buffer +/// failures are 400. Matching Display text collapses 413 into 400. +pub fn bytes_rejection_to_api_error(rejection: BytesRejection) -> ApiError { + if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE { + return ApiError::payload_too_large("request body exceeds the maximum allowed size"); + } + ApiError::malformed(format!("request body: {}", rejection.body_text())) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use serde::Deserialize; + + #[derive(Debug, Deserialize)] + struct Tiny { + x: u32, + } + + #[tokio::test] + async fn json_body_missing_content_type_is_malformed_request() { + let req = Request::builder() + .method("POST") + .uri("/") + .body(Body::from(r#"{"x":1}"#)) + .unwrap(); + let err = JsonBody::::from_request(req, &()) + .await + .expect_err("missing content-type"); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + assert_eq!(err.body.error, "malformed_request"); + assert!( + err.body.message.contains("Content-Type") + || err.body.message.contains("application/json"), + "message must name content-type rule: {}", + err.body.message + ); + } + + #[tokio::test] + async fn json_body_syntax_error_is_malformed_request() { + let req = Request::builder() + .method("POST") + .uri("/") + .header("content-type", "application/json") + .body(Body::from("{not-json")) + .unwrap(); + let err = JsonBody::::from_request(req, &()) + .await + .expect_err("bad json"); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + assert_eq!(err.body.error, "malformed_request"); + } + + #[tokio::test] + async fn json_body_happy_path() { + let req = Request::builder() + .method("POST") + .uri("/") + .header("content-type", "application/json") + .body(Body::from(r#"{"x":7}"#)) + .unwrap(); + let JsonBody(v) = JsonBody::::from_request(req, &()).await.expect("ok"); + assert_eq!(v.x, 7); + } +} diff --git a/src/grants.rs b/src/grants.rs new file mode 100644 index 0000000..6e5e0e9 --- /dev/null +++ b/src/grants.rs @@ -0,0 +1,247 @@ +//! View-grant REST surface (§7.5 L2895–L2896). +//! +//! | Method | Path | Kernel | +//! |---|---|---| +//! | `POST` | `/v1/grants/challenge` | `OpenPullChallenge` action=`issue_grant` | +//! | `POST` | `/v1/grants` | `IssueViewGrant` (after OwnershipProof) | +//! +//! A GrantProof is rejected here (no-escalation). The kernel message has no +//! capability field — only the API edge can enforce this. + +use crate::error::ApiError; +use crate::extract::JsonBody; +use crate::hexutil::{decode_hex_exact, encode_hex}; +use crate::kernel::kernel_v1::{GrantRequest, PullChallengeRequest, Scope}; +use crate::ownership::{ + decode_zk_address, encode_grant_asset_ids, issue_grant_request_hash, parse_u64_decimal, + validate_resolved_scope, verify_ownership_proof, ChallengeDomain, ChallengeEcho, + OwnerOnlyProofJson, ResolvedScope, ISSUE_GRANT_CHALLENGE_DOMAIN, SCOPE_NOT_AFTER_UNBOUNDED, +}; +use crate::state::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct GrantsChallengeBody { + pub subject: String, +} + +#[derive(Debug, Deserialize)] +pub struct GrantScopeJson { + /// Either the string `"*"` or an array of hex32 asset ids. + pub asset_ids: Value, + #[serde(default)] + pub not_before: Option, + #[serde(default)] + pub not_after: Option, +} + +#[derive(Debug, Deserialize)] +pub struct IssueGrantBody { + pub subject: String, + pub grantee_pk: String, + pub scope: GrantScopeJson, + /// Grant-level expiry (§7.1 decimal-string u64) — bound into request_hash. + pub expiry: String, + pub challenge: ChallengeEcho, + pub ownership_proof: OwnerOnlyProofJson, +} + +// --------------------------------------------------------------------------- +// Scope normalisation (§5.1 / §7.5) +// --------------------------------------------------------------------------- + +struct NormalisedScope { + all_assets: bool, + asset_ids: Vec<[u8; 32]>, + not_before: u64, + not_after: u64, +} + +/// Normalise REST scope to the single unbounded-sentinel pair **before** +/// `request_hash` and the kernel RPC (§5.1 L1918). +fn normalise_scope(scope: &GrantScopeJson) -> Result { + let (all_assets, asset_ids) = match &scope.asset_ids { + Value::String(s) if s == "*" => (true, Vec::new()), + Value::String(s) => { + return Err(ApiError::malformed(format!( + "scope.asset_ids string must be \"*\", got {s:?}" + ))); + } + Value::Array(arr) => { + let mut ids = Vec::with_capacity(arr.len()); + for (i, v) in arr.iter().enumerate() { + let hex = v.as_str().ok_or_else(|| { + ApiError::malformed(format!("scope.asset_ids[{i}] must be a hex string")) + })?; + let raw = decode_hex_exact(hex, 32) + .map_err(|e| ApiError::malformed(format!("scope.asset_ids[{i}]: {e}")))?; + let mut a = [0u8; 32]; + a.copy_from_slice(&raw); + ids.push(a); + } + if ids.is_empty() { + return Err(ApiError::malformed( + "scope.asset_ids list must be non-empty when not \"*\"", + )); + } + (false, ids) + } + other => { + return Err(ApiError::malformed(format!( + "scope.asset_ids must be \"*\" or an array of hex32, got {other}" + ))); + } + }; + + let not_before = match &scope.not_before { + None => 0u64, + Some(s) => parse_u64_decimal(s) + .map_err(|e| ApiError::malformed(format!("scope.not_before: {}", e.body.message)))?, + }; + let not_after = match &scope.not_after { + None => SCOPE_NOT_AFTER_UNBOUNDED, + Some(s) => parse_u64_decimal(s) + .map_err(|e| ApiError::malformed(format!("scope.not_after: {}", e.body.message)))?, + }; + + let resolved = ResolvedScope { + all_assets, + asset_ids, + not_before, + not_after, + }; + validate_resolved_scope(&resolved)?; + + Ok(NormalisedScope { + all_assets: resolved.all_assets, + asset_ids: resolved.asset_ids, + not_before: resolved.not_before, + not_after: resolved.not_after, + }) +} + +fn scope_to_proto(scope: &NormalisedScope) -> Scope { + Scope { + asset_ids: scope.asset_ids.iter().map(|a| a.to_vec()).collect(), + all_assets: scope.all_assets, + not_before: scope.not_before, + not_after: scope.not_after, + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /v1/grants/challenge` → OpenPullChallenge(action=issue_grant). +pub async fn post_grants_challenge( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + if body.subject.is_empty() { + return Err(ApiError::malformed("subject is required")); + } + let _ = decode_zk_address(&body.subject)?; + + let challenge = state + .kernel + .open_pull_challenge(PullChallengeRequest { + subject: body.subject, + requested_scope: None, + action: "issue_grant".to_string(), + }) + .await?; + + if challenge.nonce.len() != 32 { + return Err(ApiError::internal(format!( + "kernel Challenge.nonce must be 32 bytes, got {}", + challenge.nonce.len() + ))); + } + if challenge.domain != ISSUE_GRANT_CHALLENGE_DOMAIN { + return Err(ApiError::internal(format!( + "kernel Challenge.domain must be {ISSUE_GRANT_CHALLENGE_DOMAIN:?}, got {:?}", + challenge.domain + ))); + } + + let body = json!({ + "nonce": encode_hex(&challenge.nonce), + "expiry": challenge.expiry.to_string(), + "domain": ISSUE_GRANT_CHALLENGE_DOMAIN, + }); + Ok((StatusCode::OK, Json(body)).into_response()) +} + +/// `POST /v1/grants` → verify OwnershipProof, then IssueViewGrant. +pub async fn post_grants( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + // ---- pure validation + OwnershipProof (no kernel) ---- + let subject_raw = decode_zk_address(&body.subject)?; + let grantee_pk = { + let v = decode_hex_exact(&body.grantee_pk, 32) + .map_err(|e| ApiError::malformed(format!("grantee_pk: {e}")))?; + let mut a = [0u8; 32]; + a.copy_from_slice(&v); + a + }; + let grant_expiry = parse_u64_decimal(&body.expiry) + .map_err(|e| ApiError::malformed(format!("expiry: {}", e.body.message)))?; + let scope = normalise_scope(&body.scope)?; + let asset_enc = encode_grant_asset_ids(scope.all_assets, &scope.asset_ids)?; + + // Server-computed request_hash — never a client-supplied hash field. + let request_hash = issue_grant_request_hash( + &subject_raw, + &grantee_pk, + &asset_enc, + scope.not_before, + scope.not_after, + grant_expiry, + ); + + // GrantProof arm → 401 before any kernel call (tagged union, not 400). + let ownership_proof = body.ownership_proof.require_ownership()?; + + // Domain is the IssueGrant endpoint constant — not taken from body. + let verified = verify_ownership_proof( + ChallengeDomain::IssueGrant, + &body.subject, + &body.challenge, + &ownership_proof, + &request_hash, + state.public_hosts.as_slice(), + )?; + + // ---- only now: kernel (nonce consumption lives here) ---- + let result = state + .kernel + .issue_view_grant(GrantRequest { + subject: verified.subject_bech32, + grantee_pk: grantee_pk.to_vec(), + scope: Some(scope_to_proto(&scope)), + expiry: grant_expiry, + nonce: verified.nonce.to_vec(), + chan_bind: verified.chan_bind.to_vec(), + }) + .await?; + + if result.grant.is_empty() { + return Err(ApiError::internal( + "kernel GrantResult.grant is empty on IssueViewGrant success", + )); + } + let body = json!({ "grant": result.grant }); + Ok((StatusCode::OK, Json(body)).into_response()) +} diff --git a/src/hexutil.rs b/src/hexutil.rs new file mode 100644 index 0000000..7eb757a --- /dev/null +++ b/src/hexutil.rs @@ -0,0 +1,104 @@ +//! Lowercase hex codecs for §7.1 wire values (32-byte digests, 64-byte sigs). + +/// Decode a lowercase-or-uppercase hex string into exactly `byte_len` bytes. +/// +/// Rejects wrong length, odd nibble count, and non-hex characters. No padding +/// and no silent truncation. +pub fn decode_hex_exact(input: &str, byte_len: usize) -> Result, HexError> { + if input.len() != byte_len * 2 { + return Err(HexError::Length { + expected_chars: byte_len * 2, + got_chars: input.len(), + }); + } + let mut out = Vec::with_capacity(byte_len); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let hi = hex_nibble(bytes[i])?; + let lo = hex_nibble(bytes[i + 1])?; + out.push((hi << 4) | lo); + i += 2; + } + Ok(out) +} + +/// Encode bytes as lowercase hex (no `0x` prefix). +pub fn encode_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0xf) as usize] as char); + } + out +} + +fn hex_nibble(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(b - b'a' + 10), + b'A'..=b'F' => Ok(b - b'A' + 10), + _ => Err(HexError::InvalidChar(b)), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HexError { + Length { + expected_chars: usize, + got_chars: usize, + }, + InvalidChar(u8), +} + +impl std::fmt::Display for HexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HexError::Length { + expected_chars, + got_chars, + } => write!( + f, + "hex length must be {expected_chars} characters, got {got_chars}" + ), + HexError::InvalidChar(b) => write!(f, "invalid hex character 0x{b:02x}"), + } + } +} + +impl std::error::Error for HexError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_32() { + let raw = [0u8; 32]; + let hex = encode_hex(&raw); + assert_eq!(hex.len(), 64); + assert_eq!(decode_hex_exact(&hex, 32).unwrap(), raw); + } + + #[test] + fn rejects_wrong_length() { + let err = decode_hex_exact("ab", 32).unwrap_err(); + match err { + HexError::Length { + expected_chars, + got_chars, + } => { + assert_eq!(expected_chars, 64); + assert_eq!(got_chars, 2); + } + other => panic!("expected Length, got {other:?}"), + } + } + + #[test] + fn rejects_non_hex() { + let err = decode_hex_exact("zz", 1).unwrap_err(); + assert!(matches!(err, HexError::InvalidChar(_))); + } +} diff --git a/src/info.rs b/src/info.rs new file mode 100644 index 0000000..61badef --- /dev/null +++ b/src/info.rs @@ -0,0 +1,396 @@ +//! `GET /v1/info` and `GET /health/ready` (§7.5 L2876–L2877) over `GetInfo` (§7.8). +//! +//! `/health/ready` takes its readiness statement **only** from kernel +//! `Info.ready` / `Info.ready_reason` — one source, no second readiness +//! table. Diagnostic tip fields that appear on a successful `GetInfo` are +//! forwarded when well-formed; the api never invents tip height, lag, or +//! a NAV root of its own. +//! +//! `GET /v1/info` `features` is API configuration (`AppState.features`), +//! not `Info.kernel_parts`. The array order is **API-fixed** (lexicographic +//! by wire string); §7.5 does not prescribe it — see `info_to_json`. + +use crate::error::ApiError; +use crate::hexutil::encode_hex; +use crate::kernel::kernel_v1::{BootstrapManifest, Info}; +use crate::state::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Map, Value}; + +/// Closed §7.5 `/health/ready` `reason` set (L2876). +const READY_REASONS: &[&str] = &[ + "syncing", + "scanner_lag", + "circuit_mismatch", + "deep_reorg", + "dependency_unavailable", +]; + +/// `GET /v1/info` → `GetInfo` + API-owned `features`. +pub async fn get_info(State(state): State) -> Result { + let info = state.kernel.get_info().await?; + // When Blossom is configured, advertise the API-enforced upload limit + // (`ZKCOINS_BLOSSOM_MAX_BLOB_BYTES`), not the kernel's independent + // `Info.max_blob_bytes`. Clients must see the bound that PUT/POST + // `/blossom/upload` actually applies; publishing a higher kernel figure + // while the API rejects larger bodies would be inconsistent. Equality + // with the kernel is **not** required at boot — the REST surface is + // authoritative for the public limit when this process stores blobs. + let max_blob_override = state.blossom.as_ref().map(|b| b.max_blob_bytes); + let body = info_to_json(&info, &state.features, max_blob_override)?; + Ok((StatusCode::OK, Json(body)).into_response()) +} + +/// `GET /health/ready` → readiness projection of `GetInfo`. +/// +/// Shape is **always** `{ ready, reason? , …diags? }` — never the generic +/// `{ "error", "message" }` body (L2876). When the kernel call fails, the +/// probe answers **not ready** with `dependency_unavailable` (HTTP 503). +/// Inventing `ready: true` on a failed `GetInfo` would be the worst outcome: +/// a process that cannot ask the kernel is not ready to serve consensus- +/// dependent reads. Today's production kernel fails `GetInfo` closed when +/// `ChainIdentity` is unset; this endpoint therefore returns 503 not-ready +/// rather than a green probe. +pub async fn health_ready(State(state): State) -> Response { + match state.kernel.get_info().await { + Ok(info) => readiness_from_info(&info), + Err(err) => not_ready_dependency(err), + } +} + +fn readiness_from_info(info: &Info) -> Response { + if info.ready { + // ready == true: reason must be absent (proto optional empty / None). + if let Some(reason) = info.ready_reason.as_deref() { + if !reason.is_empty() { + // Kernel violated the structural invariant. Do not claim ready. + return not_ready_body("dependency_unavailable"); + } + } + let mut body = Map::new(); + body.insert("ready".to_string(), Value::Bool(true)); + attach_diagnostics(&mut body, info); + (StatusCode::OK, Json(Value::Object(body))).into_response() + } else { + let reason = match info.ready_reason.as_deref() { + Some(r) if is_closed_ready_reason(r) => r, + Some(_) | None => { + // Missing, empty, or non-closed reason: do not invent ready:true + // and do not pass an out-of-set token. Closed fallback reason. + return not_ready_body("dependency_unavailable"); + } + }; + let mut body = Map::new(); + body.insert("ready".to_string(), Value::Bool(false)); + body.insert("reason".to_string(), Value::String(reason.to_string())); + attach_diagnostics(&mut body, info); + (StatusCode::SERVICE_UNAVAILABLE, Json(Value::Object(body))).into_response() + } +} + +/// When `GetInfo` itself fails: not-ready, closed reason, readiness shape. +/// +/// The underlying `ApiError` message is **not** put on the wire as a +/// generic error body (that shape is excluded for this path). It is also +/// not rewritten into a different closed reason — the only honest probe +/// answer when the dependency cannot answer is `dependency_unavailable`. +fn not_ready_dependency(_err: ApiError) -> Response { + // `_err` is deliberately not projected onto the wire: /health/ready is + // excluded from the generic error body, and the closed reason set has no + // "internal_error" token. The honest readiness answer when GetInfo cannot + // complete is dependency_unavailable. + not_ready_body("dependency_unavailable") +} + +fn not_ready_body(reason: &'static str) -> Response { + debug_assert!(is_closed_ready_reason(reason)); + let mut body = Map::new(); + body.insert("ready".to_string(), Value::Bool(false)); + body.insert("reason".to_string(), Value::String(reason.to_string())); + (StatusCode::SERVICE_UNAVAILABLE, Json(Value::Object(body))).into_response() +} + +/// Diagnostic fields from a successful `GetInfo` (§7.5 L2876 MAY). +/// +/// `root` is **not** emitted here: the accumulator `root` must be paired +/// with its `size` (L2882), and `Info` carries `accumulator_root` without +/// `size`. Emitting an unpaired root would invent a half-fact. Tip height +/// and scanner lag are complete on their own and come straight from Info. +fn attach_diagnostics(body: &mut Map, info: &Info) { + body.insert( + "bitcoin_tip_height".to_string(), + json!(info.bitcoin_tip_height), + ); + body.insert("scanner_lag".to_string(), json!(info.scanner_lag)); +} + +fn is_closed_ready_reason(reason: &str) -> bool { + // Same truth value as `iter().any(|&r| r == reason)` for every input, + // including empty / non-closed strings (both false). Prefer `contains`. + READY_REASONS.contains(&reason) +} + +/// Project kernel `Info` into the §7.5 `/v1/info` JSON object (L2877). +/// +/// Pass-through fields are taken from the kernel; `features` is built +/// solely from API config. `kernel_parts`, `ready`, `ready_reason`, tip +/// diagnostics, and `accumulator_root` are **not** part of this surface. +/// +/// **`features` array order (API-fixed, intentional):** §7.5 / §6.1 close the +/// *set* of feature strings but do **not** prescribe array order. This layer +/// emits them in **lexicographic order of the wire string** +/// (`explorer` before `wallet`, …). That order is independent of env-var +/// token order and of `Feature` enum discriminant/`Ord` order — do not +/// "clean up" to input order or to enum declaration order; a public +/// response field must be bit-stable for the same enabled set. +fn info_to_json( + info: &Info, + features: &std::collections::BTreeSet, + max_blob_bytes_override: Option, +) -> Result { + let network = info.network.as_str(); + match network { + "mainnet" | "testnet" | "regtest" => {} + other => { + return Err(ApiError::internal(format!( + "kernel Info.network is not a closed tag: {other:?}" + ))); + } + } + if info.protocol_version != "v1" { + return Err(ApiError::internal(format!( + "kernel Info.protocol_version must be \"v1\", got {:?}", + info.protocol_version + ))); + } + + let circuit_digests = circuit_digests_json(&info.circuit_digests)?; + let bootstrap_pubkey = require_hex32(&info.bootstrap_pubkey, "bootstrap_pubkey")?; + let bootstrap = match &info.bootstrap { + Some(m) => bootstrap_to_json(m)?, + None => { + return Err(ApiError::internal( + "kernel Info.bootstrap is absent — BootstrapManifest is required on /v1/info", + )); + } + }; + + // BTreeSet already deduplicates; sort by wire string so the + // public array is not bound to enum Ord (Wallet < Explorer would emit + // ["wallet","explorer"] — wrong for the fixed lexicographic order). + let mut feature_names: Vec<&'static str> = features.iter().map(|f| f.as_str()).collect(); + feature_names.sort_unstable(); + let feature_list: Vec = feature_names + .into_iter() + .map(|s| Value::String(s.to_string())) + .collect(); + + // Prefer the API Blossom limit when configured; otherwise the kernel value + // (informational — no local upload path without a store). + let max_blob_bytes = match max_blob_bytes_override { + Some(api_limit) => api_limit, + None => info.max_blob_bytes, + }; + + Ok(json!({ + "network": network, + "protocol_version": "v1", + "circuit_digests": circuit_digests, + "bootstrap_pubkey": bootstrap_pubkey, + "relay_url": info.relay_url, + "blossom_url": info.blossom_url, + "max_blob_bytes": max_blob_bytes, + "finality_confirmations": info.finality_confirmations, + "activation_height": info.activation_height, + "max_tx_inputs": info.max_tx_inputs, + "max_tx_outputs": info.max_tx_outputs, + "max_rx_coins": info.max_rx_coins, + "max_account_assets": info.max_account_assets, + "features": feature_list, + "bootstrap": bootstrap, + })) +} + +fn circuit_digests_json( + digests: &std::collections::HashMap>, +) -> Result { + let c = match digests.get("C") { + Some(bytes) => require_hex32(bytes, "circuit_digests.C")?, + None => { + return Err(ApiError::internal( + "kernel Info.circuit_digests is missing key \"C\"", + )); + } + }; + let c_balance = match digests.get("C_balance") { + Some(bytes) => require_hex32(bytes, "circuit_digests.C_balance")?, + None => { + return Err(ApiError::internal( + "kernel Info.circuit_digests is missing key \"C_balance\"", + )); + } + }; + // Only the two closed keys — extra map entries from a future kernel + // are not part of §7.5 /v1/info and must not be silently advertised. + if digests.len() != 2 { + return Err(ApiError::internal(format!( + "kernel Info.circuit_digests must contain exactly C and C_balance, got {} keys", + digests.len() + ))); + } + Ok(json!({ + "C": c, + "C_balance": c_balance, + })) +} + +fn bootstrap_to_json(m: &BootstrapManifest) -> Result { + match m.network.as_str() { + "mainnet" | "testnet" | "regtest" => {} + other => { + return Err(ApiError::internal(format!( + "kernel BootstrapManifest.network is not a closed tag: {other:?}" + ))); + } + } + if m.protocol_version != "v1" { + return Err(ApiError::internal(format!( + "kernel BootstrapManifest.protocol_version must be \"v1\", got {:?}", + m.protocol_version + ))); + } + let mut operator_ids = Vec::with_capacity(m.operator_ids.len()); + for (i, id) in m.operator_ids.iter().enumerate() { + operator_ids.push(Value::String(require_hex32( + id, + &format!("bootstrap.operator_ids[{i}]"), + )?)); + } + let manifest_sig = require_hex_exact(&m.manifest_sig, 64, "bootstrap.manifest_sig")?; + Ok(json!({ + "network": m.network, + "protocol_version": m.protocol_version, + "seed_relays": m.seed_relays, + "blob_stores": m.blob_stores, + "operator_ids": operator_ids, + "issued_at": m.issued_at, + "expires_at": m.expires_at, + "manifest_sig": manifest_sig, + })) +} + +fn require_hex32(bytes: &[u8], field: &str) -> Result { + require_hex_exact(bytes, 32, field) +} + +fn require_hex_exact(bytes: &[u8], expected: usize, field: &str) -> Result { + if bytes.len() != expected { + return Err(ApiError::internal(format!( + "kernel field {field} must be {expected} bytes, got {}", + bytes.len() + ))); + } + Ok(encode_hex(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Feature; + use std::collections::{BTreeSet, HashMap}; + + fn sample_info(ready: bool, reason: Option<&str>) -> Info { + let mut circuit_digests = HashMap::new(); + circuit_digests.insert("C".to_string(), vec![0x11; 32]); + circuit_digests.insert("C_balance".to_string(), vec![0x22; 32]); + Info { + network: "regtest".into(), + protocol_version: "v1".into(), + circuit_digests, + relay_url: "wss://relay.example".into(), + blossom_url: "https://blossom.example".into(), + finality_confirmations: 6, + max_tx_inputs: 8, + max_tx_outputs: 8, + max_rx_coins: 4, + max_account_assets: 32, + ready, + bitcoin_tip_height: 100, + accumulator_root: vec![0xAA; 32], + scanner_lag: 0, + max_blob_bytes: 1_048_576, + activation_height: 0, + bootstrap: Some(BootstrapManifest { + network: "regtest".into(), + protocol_version: "v1".into(), + seed_relays: vec!["wss://seed.example".into()], + blob_stores: vec!["https://blob.example".into()], + operator_ids: vec![vec![0x33; 32]], + issued_at: 1, + expires_at: 9_999_999_999, + manifest_sig: vec![0x44; 64], + }), + kernel_parts: vec!["scanner".into()], + ready_reason: reason.map(|s| s.to_string()), + bootstrap_pubkey: vec![0x55; 32], + } + } + + #[test] + fn info_json_features_from_api_not_kernel_parts() { + let info = sample_info(true, None); + let features = BTreeSet::from([Feature::Wallet, Feature::Explorer]); + let json = info_to_json(&info, &features, None).expect("info"); + assert_eq!(json["network"], "regtest"); + assert_eq!(json["protocol_version"], "v1"); + assert_eq!(json["features"], json!(["explorer", "wallet"])); + assert_eq!(json["max_blob_bytes"], 1_048_576); + // kernel_parts must not leak onto the public surface. + assert!(json.get("kernel_parts").is_none()); + assert!(json.get("ready").is_none()); + assert_eq!(json["bootstrap_pubkey"].as_str().unwrap().len(), 64); + assert_eq!(json["circuit_digests"]["C"].as_str().unwrap().len(), 64); + assert_eq!( + json["bootstrap"]["manifest_sig"].as_str().unwrap().len(), + 128 + ); + } + + /// Without the override, a lower API Blossom limit would leave clients + /// seeing the higher kernel figure while uploads reject at the API bound. + #[test] + fn info_json_prefers_api_max_blob_bytes_when_override_set() { + let info = sample_info(true, None); + assert_eq!(info.max_blob_bytes, 1_048_576); + let features = BTreeSet::new(); + let json = info_to_json(&info, &features, Some(4096)).expect("info"); + assert_eq!( + json["max_blob_bytes"], 4096, + "API-enforced limit must be advertised when Blossom is configured" + ); + } + + #[test] + fn readiness_ready_is_200_without_reason() { + let info = sample_info(true, None); + let res = readiness_from_info(&info); + assert_eq!(res.status(), StatusCode::OK); + } + + #[test] + fn readiness_not_ready_is_503_with_closed_reason() { + let info = sample_info(false, Some("syncing")); + let res = readiness_from_info(&info); + assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn readiness_rejects_unknown_reason_as_dependency_unavailable() { + let info = sample_info(false, Some("something_else")); + let res = readiness_from_info(&info); + assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE); + } +} diff --git a/src/jobs.rs b/src/jobs.rs new file mode 100644 index 0000000..5fa7556 --- /dev/null +++ b/src/jobs.rs @@ -0,0 +1,1993 @@ +//! Job-surface REST handlers (§7.5) over kernel job procedures (§7.8). +//! +//! Endpoints (Spec-Schreibweise): `POST /v1/tx`, `GET /v1/jobs/`, +//! `GET /v1/jobs//stream`, `POST /v1/jobs//sign`, +//! `POST /v1/jobs//cancel`. Axum registers the derived `:job_id` matcher. + +use crate::error::ApiError; +use crate::extract::JsonBody; +use crate::hexutil::{decode_hex_exact, encode_hex, HexError}; +use crate::kernel::kernel_v1::{ + delivery_credential, AwaitingSignature, DeliveryCredential as ProtoDeliveryCredential, + Invoice as ProtoInvoice, Issuance, Job, JobEvent, JobHandle, JobRequest, + JobResult as ProtoJobResult, Kind0Event as ProtoKind0Event, + OutputTemplate as ProtoOutputTemplate, SignRequest, TransitionRequest, +}; +use crate::kernel::KernelHandle; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use futures_util::stream::Stream; +use futures_util::StreamExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::convert::Infallible; +use std::fmt; + +// --------------------------------------------------------------------------- +// Closed job status / error sets (§7.5 jobs family) +// --------------------------------------------------------------------------- + +/// Closed `Job.status` vocabulary on the public poll / SSE surface. +const CLOSED_JOB_STATUSES: &[&str] = &[ + "accepted", + "proving", + "awaiting_signature", + "publishing", + "completed", + "failed", + "cancelled", +]; + +/// Closed terminal `JobError.error` machine codes (§7.5 jobs-family table). +/// +/// Includes `dependency_not_final`: the productive node stores a typed +/// `DependencyNotFinal` finalise failure as this terminal machine code +/// (see node `job_dispatcher` / `v1::signature` encode path). Omitting it +/// would turn a normative terminal job failure into API `500 internal_error`. +const CLOSED_JOB_ERROR_CODES: &[&str] = &[ + "invalid_input_coin", + "insufficient_balance", + "bounds_exceeded", + "unknown_publisher", + "stale_message", + "invalid_signature", + "proving_failed", + "publish_rejected", + "circuit_digest_mismatch", + "idempotency_conflict", + "dependency_not_final", + "malformed_request", + "internal_error", +]; + +fn is_closed_job_status(status: &str) -> bool { + CLOSED_JOB_STATUSES.contains(&status) +} + +fn is_terminal_job_status(status: &str) -> bool { + matches!(status, "completed" | "failed" | "cancelled") +} + +fn is_closed_job_error_code(code: &str) -> bool { + CLOSED_JOB_ERROR_CODES.contains(&code) +} + +/// Closed `Job.kind` vocabulary on the public poll / SSE surface. +const CLOSED_JOB_KINDS: &[&str] = &["mint", "send", "receive", "attest_balance"]; + +fn is_closed_job_kind(kind: &str) -> bool { + CLOSED_JOB_KINDS.contains(&kind) +} + +fn is_transition_job_kind(kind: &str) -> bool { + matches!(kind, "mint" | "send" | "receive") +} + +/// Validate a kernel `Job` against the closed status set, status↔payload +/// exclusivity, kind-dependent result shape, and terminal error-code +/// vocabulary. Fail-closed as `500 internal_error` on any contract breach +/// (never forward foreign statuses or error codes onto the public wire). +fn validate_job(job: &Job) -> Result<(), ApiError> { + if !is_closed_job_status(&job.status) { + return Err(ApiError::internal(format!( + "kernel Job.status is not a closed §7.5 job status: {:?}", + job.status + ))); + } + if !is_closed_job_kind(&job.kind) { + return Err(ApiError::internal(format!( + "kernel Job.kind is not a closed §7.5 job kind: {:?}", + job.kind + ))); + } + + let has_awaiting = job.awaiting_signature.is_some(); + let has_result = job.result.is_some(); + let has_error = job.error.is_some(); + + // Terminal states must not carry a phase string (proto comment / §7.5). + if is_terminal_job_status(&job.status) && !job.phase.is_empty() { + return Err(ApiError::internal(format!( + "job status {} must have empty phase, got {:?}", + job.status, job.phase + ))); + } + + match job.status.as_str() { + "awaiting_signature" => { + if !has_awaiting { + return Err(ApiError::internal( + "job status is awaiting_signature but payload is absent", + )); + } + if has_result || has_error { + return Err(ApiError::internal( + "job status awaiting_signature must not carry result or error", + )); + } + if !is_transition_job_kind(&job.kind) { + return Err(ApiError::internal(format!( + "job kind {:?} must not enter awaiting_signature", + job.kind + ))); + } + } + "completed" => { + if !has_result { + return Err(ApiError::internal( + "job status is completed but result is absent", + )); + } + if has_awaiting || has_error { + return Err(ApiError::internal( + "job status completed must not carry awaiting_signature or error", + )); + } + let result = job.result.as_ref().expect("checked has_result"); + validate_job_result_for_kind(&job.kind, result)?; + } + "failed" | "cancelled" => { + if !has_error { + return Err(ApiError::internal(format!( + "job status is {} but error is absent", + job.status + ))); + } + if has_awaiting || has_result { + return Err(ApiError::internal(format!( + "job status {} must not carry awaiting_signature or result", + job.status + ))); + } + let err = job.error.as_ref().expect("checked has_error"); + if !is_closed_job_error_code(&err.error) { + return Err(ApiError::internal(format!( + "kernel JobError.error is not a closed job terminal code: {:?}", + err.error + ))); + } + } + // Non-terminal phases: no exclusive payloads. + "accepted" | "proving" | "publishing" => { + if has_awaiting || has_result || has_error { + return Err(ApiError::internal(format!( + "job status {} must not carry awaiting_signature, result, or error", + job.status + ))); + } + } + _ => unreachable!("closed set checked above"), + } + Ok(()) +} + +/// Kind-dependent completed-result shape. +/// +/// - `attest_balance`: non-empty `attestation`; no transition digest fields. +/// - `mint`/`send`/`receive`: required transition digests; no `attestation`. +fn validate_job_result_for_kind(kind: &str, result: &ProtoJobResult) -> Result<(), ApiError> { + let has_attestation = !result.attestation.is_empty(); + let has_transition_digest = !result.new_account_state_hash.is_empty() + || !result.output_coins_root.is_empty() + || !result.input_nullifiers_root.is_empty() + || !result.publisher_pubkey.is_empty() + || !result.output_coin_ids.is_empty(); + + match kind { + "attest_balance" => { + if !has_attestation { + return Err(ApiError::internal( + "attest_balance completed result must carry non-empty attestation", + )); + } + if has_transition_digest { + return Err(ApiError::internal( + "attest_balance completed result must not carry transition digest fields", + )); + } + } + "mint" | "send" | "receive" => { + if has_attestation { + return Err(ApiError::internal(format!( + "transition job kind {kind:?} must not carry attestation" + ))); + } + // Required digests for transition completion. + if result.new_account_state_hash.len() != 32 { + return Err(ApiError::internal(format!( + "transition job result.new_account_state_hash must be 32 bytes, got {}", + result.new_account_state_hash.len() + ))); + } + if result.output_coins_root.len() != 32 { + return Err(ApiError::internal(format!( + "transition job result.output_coins_root must be 32 bytes, got {}", + result.output_coins_root.len() + ))); + } + if result.input_nullifiers_root.len() != 32 { + return Err(ApiError::internal(format!( + "transition job result.input_nullifiers_root must be 32 bytes, got {}", + result.input_nullifiers_root.len() + ))); + } + } + other => { + return Err(ApiError::internal(format!( + "kernel Job.kind is not a closed §7.5 job kind: {other:?}" + ))); + } + } + Ok(()) +} + +/// SSE event name ↔ job status correlation (§7.5 L2947 / L3033). +fn validate_sse_event_status(event_name: &str, job: &Job) -> Result<(), ApiError> { + validate_job(job)?; + match event_name { + "phase" => { + if is_terminal_job_status(&job.status) { + return Err(ApiError::internal(format!( + "SSE event \"phase\" must not carry terminal status {:?}", + job.status + ))); + } + } + "complete" => { + if job.status != "completed" { + return Err(ApiError::internal(format!( + "SSE event \"complete\" requires status \"completed\", got {:?}", + job.status + ))); + } + } + "error" => { + if job.status != "failed" && job.status != "cancelled" { + return Err(ApiError::internal(format!( + "SSE event \"error\" requires status failed|cancelled, got {:?}", + job.status + ))); + } + } + _ => { + return Err(ApiError::internal(format!( + "kernel JobEvent.event is not a §7.5 SSE name: {event_name:?}" + ))); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// JSON request types (exact §7.5 shapes) +// --------------------------------------------------------------------------- + +/// §7.5 `TransitionRequest` JSON body for `POST /v1/tx` (L2898–L2930). +/// +/// §7.5: "the body is exactly this JSON object" — unknown fields are +/// `400 malformed_request`. `deny_unknown_fields` is set on **every** nested +/// object type below so a foreign key inside `output_templates[]` or +/// `issuance` is rejected the same way as one at the top level. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TransitionRequestJson { + pub kind: String, + pub subject: String, + pub next_pubkey: String, + pub npk_rand: String, + #[serde(default)] + pub input_coins: Option>, + #[serde(default)] + pub output_templates: Option>, + #[serde(default)] + pub publisher_pubkey: Option, + #[serde(default)] + pub fee_address: Option, + #[serde(default)] + pub fold_coin_ids: Option>, + #[serde(default)] + /// Recipient's genesis Pk₀ (32-byte lowercase hex, x-only); required for + /// a genesis receive (no prior transition), MUST be absent otherwise (§7.5). + pub genesis_pubkey: Option, + #[serde(default)] + pub issuance: Option, +} + +/// §7.5 `OutputTemplate`. `delivery` is optional on the wire; presence for +/// non-self outputs is enforced by the **kernel** (§7.5 presence rule), not +/// here. The API only checks form and forwards. +/// +/// **Debug** redacts `delivery` entirely — §7.5 retention: the API layer +/// **MUST NOT** log the credential (`pk0` / `memo` / signatures link the +/// recipient to its genesis on-chain nullifier key). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OutputTemplateJson { + pub recipient: String, + pub asset_id: String, + pub amount: String, + #[serde(default)] + pub delivery: Option, +} + +impl fmt::Debug for OutputTemplateJson { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OutputTemplateJson") + .field("recipient", &self.recipient) + .field("asset_id", &self.asset_id) + .field("amount", &self.amount) + .field( + "delivery", + &self + .delivery + .as_ref() + .map(|_| ""), + ) + .finish() + } +} + +/// Closed tagged union matching §7.5 `DeliveryCredential`. +/// +/// REST: `{ "type": "invoice", "invoice": … }` | `{ "type": "profile", "event": … }`. +/// Any other `type`, any structural deviation, and unknown nested fields are +/// `400 malformed_request` at the API edge. Content checks (signatures, +/// address preimage, profile kind-0 rules) are **kernel-only**. +/// +/// **Debug** never prints credential contents (same §7.5 retention rule). +#[derive(Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +pub enum DeliveryCredentialJson { + #[serde(rename = "invoice")] + Invoice { invoice: InvoiceJson }, + #[serde(rename = "profile")] + Profile { event: Kind0EventJson }, +} + +impl fmt::Debug for DeliveryCredentialJson { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Spec §7.5 retention: API MUST NOT log delivery. Name only the arm. + match self { + Self::Invoice { .. } => { + f.write_str("DeliveryCredentialJson::Invoice { /* redacted */ }") + } + Self::Profile { .. } => { + f.write_str("DeliveryCredentialJson::Profile { /* redacted */ }") + } + } + } +} + +/// Full §1.5 / §4.3 `Invoice` on the REST surface (§7.1 hex + decimal-string). +/// +/// Form only at the API: hex widths and required keys. No crypto, no address +/// preimage, no relay-URL policy. +/// +/// **`memo` (§1.5 normalisation):** Spec: "memo contributes the empty byte +/// string when absent". `None` and `Some("")` therefore both become the empty +/// proto string via `unwrap_or_default()`. Non-empty memo is copied +/// byte-for-byte (no trim). Forwarding is unchanged **except** for that +/// §1.5 memo normalisation — not a free-form "pass Option through". +/// +/// **Debug** redacts `pk0`, `memo`, and both signatures. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InvoiceJson { + pub amount: String, + pub recipient: String, + pub asset_id: String, + #[serde(default)] + pub memo: Option, + pub pk0: String, + pub nk_commit: String, + pub ivpk: String, + pub op_pubkey: String, + pub relays: Vec, + pub addr_sig: String, + pub sig: String, +} + +impl fmt::Debug for InvoiceJson { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // §7.5: after a successful check the kernel retains only + // {ivpk, op_pubkey, relays}; pk0 / memo / signatures MUST NOT be + // logged. The API never verifies — and still MUST NOT log them. + f.debug_struct("InvoiceJson") + .field("amount", &self.amount) + .field("recipient", &self.recipient) + .field("asset_id", &self.asset_id) + .field( + "memo", + &self + .memo + .as_ref() + .map(|_| ""), + ) + .field("pk0", &"") + .field("nk_commit", &"") + .field("ivpk", &"") + .field("op_pubkey", &"") + .field("relays", &self.relays.len()) + .field("addr_sig", &"") + .field("sig", &"") + .finish() + } +} + +/// Canonical NIP-01 kind-0 event shape on the REST surface (`type: "profile"`). +/// +/// Binary fields are lowercase-or-uppercase hex of exact width. `tags` is the +/// JSON array of tag arrays; the API serialises it to `Kind0Event.tags_json` +/// without reformatting the `content` string. +/// +/// **Debug** redacts id / pubkey / content / sig (content holds the `zkcoins` +/// object including `pk0`). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Kind0EventJson { + pub id: String, + pub pubkey: String, + pub created_at: u64, + pub kind: u32, + pub tags: Vec>, + pub content: String, + pub sig: String, +} + +impl fmt::Debug for Kind0EventJson { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Kind0EventJson") + .field("id", &"") + .field("pubkey", &"") + .field("created_at", &self.created_at) + .field("kind", &self.kind) + .field("tags", &self.tags.len()) + .field("content", &"") + .field("sig", &"") + .finish() + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IssuanceJson { + pub name: String, + pub decimals: u32, + pub issuance_version: u32, + pub amount: String, + /// Genesis spend key `Pk₀` (32-byte lowercase hex); required for both versions. + pub creator_pubkey: String, + #[serde(default)] + pub cap_total: Option, + #[serde(default)] + pub terms_salt: Option, +} + +/// §7.5 sign body (L2891): `{ signature: , s2c_nonce: }`. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SignBodyJson { + pub signature: String, + pub s2c_nonce: String, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /v1/tx` → `SubmitTransition` → `202 { job_id, status: "accepted" }`. +/// +/// Body is deserialized via [`JsonBody`] so unknown fields, content-type +/// failures, and other serde rejections become `400 malformed_request` (not +/// axum's default 422 with a non-§7.5 body). +/// +/// **Retention (§7.5 `delivery`):** this handler never logs the request body +/// and never interpolates credential fields into success paths. Form-error +/// messages name field *paths* and form classes only — not `pk0` hex or +/// `memo` text. `Debug` on the JSON types redacts `delivery` for the same +/// reason (see `OutputTemplateJson` / `InvoiceJson`). +pub async fn post_tx( + State(kernel): State, + headers: HeaderMap, + JsonBody(body): JsonBody, +) -> Result { + let mut req = json_to_transition(body)?; + // Missing header ⇒ leave proto field empty (kernel treats empty as absent). + // Present-but-empty is a client error, not silently rewritten to absent. + if let Some(key) = idempotency_key_from_headers(&headers)? { + req.idempotency_key = key; + } + let handle: JobHandle = kernel.submit_transition(req).await?; + // Spec §7.5: 202 is the only success for POST /v1/tx, and the body is + // `{ job_id, status: "accepted" }`. An empty job_id or non-accepted + // status is a kernel contract violation — never admit as success + // (same discipline as AttestBalance in `attest.rs`). + if handle.job_id.is_empty() { + return Err(ApiError::internal( + "kernel JobHandle.job_id is empty on SubmitTransition success", + )); + } + if handle.status != "accepted" { + return Err(ApiError::internal(format!( + "kernel JobHandle.status must be \"accepted\" on submit success, got {:?}", + handle.status + ))); + } + let body = json!({ + "job_id": handle.job_id, + "status": "accepted", + }); + Ok((StatusCode::ACCEPTED, Json(body)).into_response()) +} + +/// `GET /v1/jobs/` → `GetJob`. +pub async fn get_job( + State(kernel): State, + Path(job_id): Path, +) -> Result { + if job_id.is_empty() { + return Err(ApiError::malformed("job_id must not be empty")); + } + let job = kernel + .get_job(JobRequest { + job_id: job_id.clone(), + }) + .await?; + let (status_header, retry_after) = job_poll_headers(&job)?; + let mut response = (status_header, Json(job_to_json(&job)?)).into_response(); + if let Some(secs) = retry_after { + response.headers_mut().insert( + axum::http::header::RETRY_AFTER, + HeaderValue::from_str(&secs.to_string()) + .map_err(|e| ApiError::internal(format!("invalid Retry-After value: {e}")))?, + ); + } + Ok(response) +} + +/// `GET /v1/jobs//stream` → `StreamJob` as SSE. +/// +/// Start failures of `StreamJob` (unknown job, transport, ErrorInfo domain +/// errors) return **before** the SSE response is opened: HTTP status + §7.5 +/// JSON body via [`ApiError`]. Only a successful stream handshake upgrades +/// the response to `text/event-stream`. +pub async fn stream_job( + State(kernel): State, + Path(job_id): Path, +) -> Result> + Send + 'static>, ApiError> { + if job_id.is_empty() { + return Err(ApiError::malformed("job_id must not be empty")); + } + // Await the kernel stream handshake first. On `Err`, axum maps `ApiError` + // to a normal HTTP response (status + JSON body) and never enters SSE. + let stream = kernel.stream_job(JobRequest { job_id }).await?; + + let sse_stream = job_event_sse_stream(stream); + Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default())) +} + +/// `POST /v1/jobs//sign` → `SignTransition`. +pub async fn post_sign( + State(kernel): State, + Path(job_id): Path, + JsonBody(body): JsonBody, +) -> Result { + if job_id.is_empty() { + return Err(ApiError::malformed("job_id must not be empty")); + } + let signature = decode_hex_exact(&body.signature, 64) + .map_err(|e| ApiError::malformed(format!("signature: {e}")))?; + let s2c_nonce = decode_hex_exact(&body.s2c_nonce, 32) + .map_err(|e| ApiError::malformed(format!("s2c_nonce: {e}")))?; + let job = kernel + .sign_transition(SignRequest { + job_id, + signature, + s2c_nonce, + }) + .await?; + Ok((StatusCode::OK, Json(job_to_json(&job)?)).into_response()) +} + +/// `POST /v1/jobs//cancel` → `CancelJob`. +pub async fn post_cancel( + State(kernel): State, + Path(job_id): Path, +) -> Result { + if job_id.is_empty() { + return Err(ApiError::malformed("job_id must not be empty")); + } + let job = kernel.cancel_job(JobRequest { job_id }).await?; + Ok((StatusCode::OK, Json(job_to_json(&job)?)).into_response()) +} + +// --------------------------------------------------------------------------- +// SSE +// --------------------------------------------------------------------------- + +fn job_event_sse_stream(stream: S) -> impl Stream> + Send +where + S: Stream> + Send + 'static, +{ + // Map each kernel event to one SSE frame. On stream break, emit a single + // recognizable `error` frame then end — never hang open with silence. + // + // `take_while` + stateful scan: after a terminal event (`complete` / + // `error`) or a stream-break frame we stop polling the kernel stream. + async_stream_events(stream) +} + +fn async_stream_events(stream: S) -> impl Stream> + Send +where + S: Stream> + Send + 'static, +{ + futures_util::stream::unfold((Box::pin(stream), false), |(mut stream, done)| async move { + if done { + return None; + } + match stream.next().await { + None => None, + Some(Ok(ev)) => { + let terminal = is_terminal_event_name(&ev.event); + match job_event_to_sse(&ev) { + Ok(frame) => Some((Ok(frame), (stream, terminal))), + Err(api_err) => { + let frame = stream_break_event(&api_err); + Some((Ok(frame), (stream, true))) + } + } + } + Some(Err(api_err)) => { + let frame = stream_break_event(&api_err); + Some((Ok(frame), (stream, true))) + } + } + }) +} + +fn is_terminal_event_name(name: &str) -> bool { + name == "complete" || name == "error" +} + +fn stream_break_event(err: &ApiError) -> Event { + // Recognizable end: an `error` event with the §7.5 error body shape. + // Clients must not wait forever on a half-open SSE subscription. + let data = json!({ + "status": "failed", + "error": { + "error": err.body.error, + "message": err.body.message, + } + }); + Event::default().event("error").data(data.to_string()) +} + +fn job_event_to_sse(ev: &JobEvent) -> Result { + let name = ev.event.as_str(); + let job = match &ev.job { + Some(j) => j, + None => { + return Err(ApiError::internal( + "kernel JobEvent is missing the job payload", + )); + } + }; + // Closed event name + status correlation + payload exclusivity. + validate_sse_event_status(name, job)?; + let data = match name { + "phase" => phase_event_data(job)?, + "complete" | "error" => job_to_json(job)?, + _ => unreachable!("validate_sse_event_status checked name"), + }; + Ok(Event::default().event(name).data(data.to_string())) +} + +/// §7.5 L2947 phase frame: `{ status, phase?, progress }`. +fn phase_event_data(job: &Job) -> Result { + let mut obj = serde_json::Map::new(); + obj.insert("status".to_string(), Value::String(job.status.clone())); + if !job.phase.is_empty() { + obj.insert("phase".to_string(), Value::String(job.phase.clone())); + } + obj.insert("progress".to_string(), json!(job.progress)); + // When status is awaiting_signature, embed the surface inline (L3033). + if job.status == "awaiting_signature" { + if let Some(a) = &job.awaiting_signature { + obj.insert( + "awaiting_signature".to_string(), + awaiting_signature_json(a)?, + ); + } + } + Ok(Value::Object(obj)) +} + +// --------------------------------------------------------------------------- +// JSON ↔ proto +// --------------------------------------------------------------------------- + +fn json_to_transition(body: TransitionRequestJson) -> Result { + let kind = body.kind; + match kind.as_str() { + "mint" | "send" | "receive" => {} + other => { + return Err(ApiError::malformed(format!( + "kind must be mint|send|receive, got {other:?}" + ))); + } + } + + // v1: fee_address MUST be absent (L2933–L2939). + if body.fee_address.is_some() { + return Err(ApiError::malformed( + "fee_address must be absent in v1 (publisher presence matrix)", + )); + } + + let next_pubkey = decode_hex_field(&body.next_pubkey, 32, "next_pubkey")?; + let npk_rand = decode_hex_field(&body.npk_rand, 32, "npk_rand")?; + + let publisher_pubkey = match body.publisher_pubkey { + Some(hex) => decode_hex_field(&hex, 32, "publisher_pubkey")?, + None => Vec::new(), + }; + + let input_coins = match body.input_coins { + Some(list) => { + let mut out = Vec::with_capacity(list.len()); + for (i, h) in list.iter().enumerate() { + out.push(decode_hex_field(h, 32, &format!("input_coins[{i}]"))?); + } + out + } + None => Vec::new(), + }; + + let fold_coin_ids = match body.fold_coin_ids { + Some(list) => { + let mut out = Vec::with_capacity(list.len()); + for (i, h) in list.iter().enumerate() { + out.push(decode_hex_field(h, 32, &format!("fold_coin_ids[{i}]"))?); + } + out + } + None => Vec::new(), + }; + + let genesis_pubkey = match body.genesis_pubkey { + Some(hex) => decode_hex_field(&hex, 32, "genesis_pubkey")?, + None => Vec::new(), + }; + + let output_templates = match body.output_templates { + Some(list) => { + let mut out = Vec::with_capacity(list.len()); + for (i, t) in list.into_iter().enumerate() { + out.push(json_to_output_template(t, i)?); + } + out + } + None => Vec::new(), + }; + + let issuance = match body.issuance { + Some(iss) => Some(json_to_issuance(iss)?), + None => None, + }; + + // Presence rules (§7.5 L2907–L2941) that the API can enforce without kernel: + // kind-dependent required fields. Remaining bounds stay kernel-side. + match kind.as_str() { + "send" => { + if input_coins.is_empty() { + return Err(ApiError::malformed( + "kind=send requires non-empty input_coins", + )); + } + if output_templates.is_empty() { + return Err(ApiError::malformed( + "kind=send requires non-empty output_templates", + )); + } + if !fold_coin_ids.is_empty() { + return Err(ApiError::malformed( + "kind=send must not carry fold_coin_ids", + )); + } + if issuance.is_some() { + return Err(ApiError::malformed("kind=send must not carry issuance")); + } + if !genesis_pubkey.is_empty() { + return Err(ApiError::malformed( + "kind=send must not carry genesis_pubkey", + )); + } + } + "mint" => { + if !input_coins.is_empty() { + return Err(ApiError::malformed("kind=mint must not carry input_coins")); + } + if !fold_coin_ids.is_empty() { + return Err(ApiError::malformed( + "kind=mint must not carry fold_coin_ids", + )); + } + if output_templates.is_empty() { + return Err(ApiError::malformed( + "kind=mint requires non-empty output_templates", + )); + } + if issuance.is_none() { + return Err(ApiError::malformed("kind=mint requires issuance")); + } + if !genesis_pubkey.is_empty() { + return Err(ApiError::malformed( + "kind=mint must not carry genesis_pubkey", + )); + } + } + "receive" => { + if !input_coins.is_empty() { + return Err(ApiError::malformed( + "kind=receive must not carry input_coins", + )); + } + if !output_templates.is_empty() { + return Err(ApiError::malformed( + "kind=receive must not carry output_templates", + )); + } + if fold_coin_ids.is_empty() { + return Err(ApiError::malformed( + "kind=receive requires non-empty fold_coin_ids", + )); + } + if issuance.is_some() { + return Err(ApiError::malformed("kind=receive must not carry issuance")); + } + } + _ => unreachable!("kind checked above"), + } + + if body.subject.is_empty() { + return Err(ApiError::malformed("subject is required")); + } + + Ok(TransitionRequest { + kind, + subject: body.subject, + next_pubkey, + npk_rand, + input_coins, + output_templates, + publisher_pubkey, + fee_address: String::new(), + fold_coin_ids, + issuance, + genesis_pubkey, + idempotency_key: String::new(), + }) +} + +/// REST → proto for one `OutputTemplate`, including optional `delivery`. +/// +/// Hex is form-checked (width + charset) and decoded; strings (`recipient`, +/// `amount`, relays, content) pass through unchanged (no trim). Invoice +/// `memo` is normalised per §1.5 (absent → empty byte string); see +/// [`InvoiceJson`]. Credential **content** is never inspected. +fn json_to_output_template( + t: OutputTemplateJson, + index: usize, +) -> Result { + let prefix = format!("output_templates[{index}]"); + let asset_id = decode_hex_field(&t.asset_id, 32, &format!("{prefix}.asset_id"))?; + let delivery = match t.delivery { + None => None, + Some(cred) => Some(json_to_delivery_credential(cred, &prefix)?), + }; + Ok(ProtoOutputTemplate { + recipient: t.recipient, + asset_id, + amount: t.amount, + delivery, + }) +} + +/// REST closed tagged union → proto `DeliveryCredential` oneof. +/// +/// Maps `type: "invoice"` → `body = Invoice`, `type: "profile"` → +/// `body = ProfileEvent`. Unknown `type` is already rejected by serde at the +/// JSON edge. Error messages name only field paths and form classes — never +/// credential bytes or memo text (§7.5 retention). +fn json_to_delivery_credential( + cred: DeliveryCredentialJson, + output_prefix: &str, +) -> Result { + let prefix = format!("{output_prefix}.delivery"); + let body = match cred { + DeliveryCredentialJson::Invoice { invoice } => { + delivery_credential::Body::Invoice(json_to_invoice(invoice, &prefix)?) + } + DeliveryCredentialJson::Profile { event } => { + delivery_credential::Body::ProfileEvent(json_to_kind0_event(event, &prefix)?) + } + }; + Ok(ProtoDeliveryCredential { body: Some(body) }) +} + +fn json_to_invoice(inv: InvoiceJson, delivery_prefix: &str) -> Result { + let p = format!("{delivery_prefix}.invoice"); + // Form only: exact hex widths. Do not trim strings; do not parse amount as + // u128; do not require non-empty relays (kernel check-list). + let asset_id = decode_hex_field(&inv.asset_id, 32, &format!("{p}.asset_id"))?; + let pk0 = decode_hex_field(&inv.pk0, 32, &format!("{p}.pk0"))?; + let nk_commit = decode_hex_field(&inv.nk_commit, 32, &format!("{p}.nk_commit"))?; + let ivpk = decode_hex_field(&inv.ivpk, 32, &format!("{p}.ivpk"))?; + let op_pubkey = decode_hex_field(&inv.op_pubkey, 32, &format!("{p}.op_pubkey"))?; + let addr_sig = decode_hex_field(&inv.addr_sig, 64, &format!("{p}.addr_sig"))?; + let sig = decode_hex_field(&inv.sig, 64, &format!("{p}.sig"))?; + // §1.5: memo contributes the empty byte string when absent. Present empty + // and present non-empty (no trim) map unchanged except that normalisation. + let memo = inv.memo.unwrap_or_default(); + Ok(ProtoInvoice { + amount: inv.amount, + recipient: inv.recipient, + asset_id, + memo, + pk0, + nk_commit, + ivpk, + op_pubkey, + relays: inv.relays, + addr_sig, + sig, + }) +} + +fn json_to_kind0_event( + ev: Kind0EventJson, + delivery_prefix: &str, +) -> Result { + let p = format!("{delivery_prefix}.event"); + // Form only: hex widths. kind == 0 and NIP-01 verification are kernel-side. + let id = decode_hex_field(&ev.id, 32, &format!("{p}.id"))?; + let pubkey = decode_hex_field(&ev.pubkey, 32, &format!("{p}.pubkey"))?; + let sig = decode_hex_field(&ev.sig, 64, &format!("{p}.sig"))?; + // tags → tags_json: canonical JSON array, no pretty-print. Failure here is + // structural (tags not serialisable) — message names the path only. + let tags_json = serde_json::to_string(&ev.tags).map_err(|_| { + ApiError::malformed(format!( + "{p}.tags must be a JSON-serialisable array of string arrays" + )) + })?; + Ok(ProtoKind0Event { + id, + pubkey, + created_at: ev.created_at, + kind: ev.kind, + tags_json, + content: ev.content, + sig, + }) +} + +fn json_to_issuance(iss: IssuanceJson) -> Result { + if iss.issuance_version != 1 && iss.issuance_version != 2 { + return Err(ApiError::malformed("issuance_version must be 1 or 2")); + } + let creator_pubkey = decode_hex_field(&iss.creator_pubkey, 32, "creator_pubkey")?; + if iss.issuance_version == 2 { + let cap = match iss.cap_total { + Some(c) => c, + None => { + return Err(ApiError::malformed("issuance_version=2 requires cap_total")); + } + }; + let salt = match iss.terms_salt { + Some(s) => decode_hex_field(&s, 32, "terms_salt")?, + None => { + return Err(ApiError::malformed( + "issuance_version=2 requires terms_salt", + )); + } + }; + Ok(Issuance { + name: iss.name, + decimals: iss.decimals, + issuance_version: iss.issuance_version, + amount: iss.amount, + cap_total: cap, + terms_salt: salt, + creator_pubkey, + }) + } else { + if iss.cap_total.is_some() || iss.terms_salt.is_some() { + return Err(ApiError::malformed( + "issuance_version=1 must not carry cap_total or terms_salt", + )); + } + Ok(Issuance { + name: iss.name, + decimals: iss.decimals, + issuance_version: iss.issuance_version, + amount: iss.amount, + cap_total: String::new(), + terms_salt: Vec::new(), + creator_pubkey, + }) + } +} + +fn decode_hex_field(hex: &str, byte_len: usize, field: &str) -> Result, ApiError> { + decode_hex_exact(hex, byte_len) + .map_err(|e: HexError| ApiError::malformed(format!("{field}: {e}"))) +} + +/// Parse the §7.5 `Idempotency-Key` request header. +/// +/// - **Absent** → `Ok(None)` — caller leaves the proto field empty (missing). +/// - **Present but empty** → `400 malformed_request` (empty ≠ missing). +/// - **Present, non-empty, ≤ 64 bytes, ASCII** → `Ok(Some(key))`. +pub(crate) fn idempotency_key_from_headers( + headers: &HeaderMap, +) -> Result, ApiError> { + let Some(raw) = headers.get("idempotency-key") else { + return Ok(None); + }; + let s = raw + .to_str() + .map_err(|_| ApiError::malformed("Idempotency-Key must be ASCII"))?; + parse_idempotency_key_value(s) +} + +/// Validate a present `Idempotency-Key` value (header already observed). +/// +/// Separated from header extraction so empty-vs-missing can be unit-tested +/// without depending on `http::HeaderValue` (which rejects empty bytes). +pub(crate) fn parse_idempotency_key_value(s: &str) -> Result, ApiError> { + if s.is_empty() { + return Err(ApiError::malformed( + "Idempotency-Key header is present but empty", + )); + } + if s.len() > 64 { + return Err(ApiError::malformed("Idempotency-Key exceeds 64 bytes")); + } + Ok(Some(s.to_string())) +} + +/// §7.5 job poll object (L2889, L2959–L2991). +fn job_to_json(job: &Job) -> Result { + validate_job(job)?; + + let mut obj = serde_json::Map::new(); + obj.insert("job_id".to_string(), Value::String(job.job_id.clone())); + obj.insert("kind".to_string(), Value::String(job.kind.clone())); + obj.insert("status".to_string(), Value::String(job.status.clone())); + // phase absent in terminal states (L2889). + if !is_terminal_job_status(&job.status) && !job.phase.is_empty() { + obj.insert("phase".to_string(), Value::String(job.phase.clone())); + } + obj.insert("progress".to_string(), json!(job.progress)); + + if job.status == "awaiting_signature" { + let a = job + .awaiting_signature + .as_ref() + .expect("validate_job checked"); + obj.insert( + "awaiting_signature".to_string(), + awaiting_signature_json(a)?, + ); + } + + if job.status == "completed" { + let r = job.result.as_ref().expect("validate_job checked"); + obj.insert("result".to_string(), job_result_json(r)?); + } + + if job.status == "failed" || job.status == "cancelled" { + let e = job.error.as_ref().expect("validate_job checked"); + // Neutralise internal diagnostics on the public wire (poll + SSE). + let public_message = if e.error == "internal_error" { + tracing::error!( + job_id = %job.job_id, + message = %e.message, + "job terminal internal_error (operator diagnostic only)" + ); + crate::error::PUBLIC_INTERNAL_MESSAGE.to_string() + } else { + e.message.clone() + }; + obj.insert( + "error".to_string(), + json!({ "error": e.error, "message": public_message }), + ); + } + + Ok(Value::Object(obj)) +} + +fn awaiting_signature_json(a: &AwaitingSignature) -> Result { + // All digests are required 32-byte values on the wire (L2961–L2970). + Ok(json!({ + "new_account_state_hash": require_hex32(&a.new_account_state_hash, "new_account_state_hash")?, + "output_coins_root": require_hex32(&a.output_coins_root, "output_coins_root")?, + "input_nullifiers_root": require_hex32(&a.input_nullifiers_root, "input_nullifiers_root")?, + "coin_history_root": require_hex32(&a.coin_history_root, "coin_history_root")?, + "nav_commitment": require_hex32(&a.nav_commitment, "nav_commitment")?, + "npk_commit": require_hex32(&a.npk_commit, "npk_commit")?, + "proof_data_hash": require_hex32(&a.proof_data_hash, "proof_data_hash")?, + "txn_pubkey": require_hex32(&a.txn_pubkey, "txn_pubkey")?, + "send_counter": a.send_counter, + })) +} + +/// Project a completed `JobResult` already validated by [`validate_job`]. +/// +/// Kind-dependent presence is enforced in `validate_job_result_for_kind`; +/// this helper only formats present fields. +fn job_result_json(r: &ProtoJobResult) -> Result { + let mut obj = serde_json::Map::new(); + if !r.new_account_state_hash.is_empty() { + obj.insert( + "new_account_state_hash".to_string(), + Value::String(require_hex32( + &r.new_account_state_hash, + "result.new_account_state_hash", + )?), + ); + } + if !r.output_coins_root.is_empty() { + obj.insert( + "output_coins_root".to_string(), + Value::String(require_hex32( + &r.output_coins_root, + "result.output_coins_root", + )?), + ); + } + if !r.input_nullifiers_root.is_empty() { + obj.insert( + "input_nullifiers_root".to_string(), + Value::String(require_hex32( + &r.input_nullifiers_root, + "result.input_nullifiers_root", + )?), + ); + } + let mut coin_ids = Vec::with_capacity(r.output_coin_ids.len()); + for (i, id) in r.output_coin_ids.iter().enumerate() { + coin_ids.push(require_hex32(id, &format!("result.output_coin_ids[{i}]"))?); + } + // Transition jobs always expose the (possibly empty) coin-id list. + // Attest jobs have no coin ids — omit the field when empty and attestation + // is present so clients do not see a meaningless empty array. + if !coin_ids.is_empty() || r.attestation.is_empty() { + obj.insert("output_coin_ids".to_string(), json!(coin_ids)); + } + + if !r.publisher_pubkey.is_empty() { + obj.insert( + "publisher_pubkey".to_string(), + Value::String(require_hex32( + &r.publisher_pubkey, + "result.publisher_pubkey", + )?), + ); + } + if !r.attestation.is_empty() { + obj.insert( + "attestation".to_string(), + Value::String(encode_hex(&r.attestation)), + ); + } + Ok(Value::Object(obj)) +} + +fn require_hex32(bytes: &[u8], field: &str) -> Result { + if bytes.len() != 32 { + return Err(ApiError::internal(format!( + "kernel field {field} must be 32 bytes, got {}", + bytes.len() + ))); + } + Ok(encode_hex(bytes)) +} + +/// Poll headers: 200 always on success; Retry-After on non-terminal (L2944). +/// +/// Caller must already have [`validate_job`]'d — unknown status is not treated +/// as non-terminal (that would invent a retry schedule for foreign values). +fn job_poll_headers(job: &Job) -> Result<(StatusCode, Option), ApiError> { + validate_job(job)?; + if is_terminal_job_status(&job.status) { + return Ok((StatusCode::OK, None)); + } + let secs = match job.status.as_str() { + "awaiting_signature" => 0, + _ => 2, // proving / publishing / accepted — RECOMMENDED 2 (L2944) + }; + Ok((StatusCode::OK, Some(secs))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kernel::kernel_v1::delivery_credential::Body as DeliveryBody; + use axum::http::HeaderMap; + + fn hex32(byte: u8) -> String { + crate::hexutil::encode_hex(&[byte; 32]) + } + + fn hex64(byte: u8) -> String { + crate::hexutil::encode_hex(&[byte; 64]) + } + + /// Distinctive 32-byte hex that must never appear in logs / error text. + fn distinctive_pk0() -> String { + // Unique nibble pattern so substring false-positives are unlikely. + "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90".to_string() + } + + fn distinctive_memo() -> String { + "MEMO_RETENTION_MARKER_DO_NOT_LOG_xyz".to_string() + } + + fn sample_invoice_json() -> serde_json::Value { + serde_json::json!({ + "amount": "100", + "recipient": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "asset_id": hex32(0x33), + "memo": distinctive_memo(), + "pk0": distinctive_pk0(), + "nk_commit": hex32(0x44), + "ivpk": hex32(0x55), + "op_pubkey": hex32(0x66), + "relays": ["wss://relay.example"], + "addr_sig": hex64(0x77), + "sig": hex64(0x88), + }) + } + + fn sample_profile_event_json() -> serde_json::Value { + serde_json::json!({ + "id": hex32(0x91), + "pubkey": hex32(0x92), + "created_at": 1_700_000_000_u64, + "kind": 0, + "tags": [], + "content": format!( + "{{\"zkcoins\":{{\"pk0\":\"{}\",\"memo\":\"should-not-matter\"}}}}", + distinctive_pk0() + ), + "sig": hex64(0x93), + }) + } + + fn mint_json() -> serde_json::Value { + serde_json::json!({ + "kind": "mint", + "subject": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "next_pubkey": hex32(0x11), + "npk_rand": hex32(0x22), + "output_templates": [{ + "recipient": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "asset_id": hex32(0x33), + "amount": "100" + }], + "issuance": { + "name": "TestCoin", + "decimals": 8, + "issuance_version": 1, + "amount": "1000", + "creator_pubkey": hex32(0x44) + } + }) + } + + fn mint_with_invoice_delivery() -> serde_json::Value { + let mut v = mint_json(); + v["output_templates"][0]["delivery"] = serde_json::json!({ + "type": "invoice", + "invoice": sample_invoice_json(), + }); + v + } + + fn mint_with_profile_delivery() -> serde_json::Value { + let mut v = mint_json(); + v["output_templates"][0]["delivery"] = serde_json::json!({ + "type": "profile", + "event": sample_profile_event_json(), + }); + v + } + + fn send_two_outputs_with_deliveries() -> serde_json::Value { + let inv0 = sample_invoice_json(); + let mut inv1 = sample_invoice_json(); + inv1["amount"] = serde_json::json!("200"); + inv1["pk0"] = serde_json::json!(hex32(0xAB)); + inv1["memo"] = serde_json::json!("second-output-memo"); + serde_json::json!({ + "kind": "send", + "subject": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "next_pubkey": hex32(0x11), + "npk_rand": hex32(0x22), + "input_coins": [hex32(0x01)], + "output_templates": [ + { + "recipient": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "asset_id": hex32(0x33), + "amount": "100", + "delivery": { "type": "invoice", "invoice": inv0 } + }, + { + "recipient": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "asset_id": hex32(0x33), + "amount": "200", + "delivery": { "type": "invoice", "invoice": inv1 } + } + ] + }) + } + + #[test] + fn idempotency_missing_header_is_none() { + let headers = HeaderMap::new(); + let got = idempotency_key_from_headers(&headers).expect("ok"); + assert_eq!(got, None, "absent header must stay None, not empty string"); + } + + /// Present-but-empty is a client error. Distinct from missing (`None`). + /// + /// Tested at the value layer: `http::HeaderValue` rejects empty bytes, so + /// an HTTP request builder cannot construct this case — the wire still + /// requires the same rule when a stack delivers an empty value. + #[test] + fn idempotency_empty_value_is_malformed_not_none() { + let err = parse_idempotency_key_value("").expect_err("empty"); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + assert_eq!(err.body.error, "malformed_request"); + // Contrast: missing header is Ok(None), not an error. + let headers = HeaderMap::new(); + assert!(idempotency_key_from_headers(&headers).unwrap().is_none()); + } + + #[test] + fn idempotency_nonempty_header_is_some() { + let mut headers = HeaderMap::new(); + headers.insert("idempotency-key", "abc".parse().unwrap()); + let got = idempotency_key_from_headers(&headers).expect("ok"); + assert_eq!(got.as_deref(), Some("abc")); + } + + #[test] + fn transition_request_rejects_unknown_top_level_field() { + let mut v = mint_json(); + v["not_in_spec"] = serde_json::json!(true); + let err = serde_json::from_value::(v).expect_err("deny"); + assert!( + err.to_string().contains("not_in_spec") || err.to_string().contains("unknown field"), + "serde must reject unknown field, got {err}" + ); + } + + #[test] + fn transition_request_rejects_unknown_nested_issuance_field() { + let mut v = mint_json(); + v["issuance"]["ghost"] = serde_json::json!("x"); + let err = serde_json::from_value::(v).expect_err("deny nested"); + assert!( + err.to_string().contains("ghost") || err.to_string().contains("unknown field"), + "nested deny_unknown_fields must fire, got {err}" + ); + } + + #[test] + fn transition_request_rejects_unknown_nested_output_template_field() { + let mut v = mint_json(); + v["output_templates"][0]["extra"] = serde_json::json!(1); + let err = serde_json::from_value::(v).expect_err("deny nested ot"); + assert!( + err.to_string().contains("extra") || err.to_string().contains("unknown field"), + "output_templates deny_unknown_fields must fire, got {err}" + ); + } + + #[test] + fn transition_request_accepts_exact_mint_shape() { + let v = mint_json(); + let parsed: TransitionRequestJson = + serde_json::from_value(v).expect("exact shape must parse"); + assert_eq!(parsed.kind, "mint"); + } + + // ----------------------------------------------------------------------- + // Delivery credential: form edge + field-for-field forward + // ----------------------------------------------------------------------- + + #[test] + fn invoice_delivery_forwards_field_for_field() { + let parsed: TransitionRequestJson = + serde_json::from_value(mint_with_invoice_delivery()).expect("parse"); + let req = json_to_transition(parsed).expect("convert"); + assert_eq!(req.output_templates.len(), 1); + let ot = &req.output_templates[0]; + let cred = ot.delivery.as_ref().expect("delivery present"); + let inv = match cred.body.as_ref().expect("oneof set") { + DeliveryBody::Invoice(i) => i, + other => panic!("expected Invoice arm, got {other:?}"), + }; + assert_eq!(inv.amount, "100"); + assert_eq!( + inv.recipient, + "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq" + ); + assert_eq!(inv.asset_id, vec![0x33; 32]); + assert_eq!(inv.memo, distinctive_memo()); + assert_eq!(inv.pk0, decode_hex_exact(&distinctive_pk0(), 32).unwrap()); + assert_eq!(inv.nk_commit, vec![0x44; 32]); + assert_eq!(inv.ivpk, vec![0x55; 32]); + assert_eq!(inv.op_pubkey, vec![0x66; 32]); + assert_eq!(inv.relays, vec!["wss://relay.example".to_string()]); + assert_eq!(inv.addr_sig, vec![0x77; 64]); + assert_eq!(inv.sig, vec![0x88; 64]); + // Output template fields unchanged alongside delivery. + assert_eq!(ot.amount, "100"); + assert_eq!(ot.asset_id, vec![0x33; 32]); + } + + #[test] + fn profile_delivery_forwards_field_for_field() { + let parsed: TransitionRequestJson = + serde_json::from_value(mint_with_profile_delivery()).expect("parse"); + let req = json_to_transition(parsed).expect("convert"); + let cred = req.output_templates[0] + .delivery + .as_ref() + .expect("delivery present"); + let ev = match cred.body.as_ref().expect("oneof set") { + DeliveryBody::ProfileEvent(e) => e, + other => panic!("expected ProfileEvent arm, got {other:?}"), + }; + assert_eq!(ev.id, vec![0x91; 32]); + assert_eq!(ev.pubkey, vec![0x92; 32]); + assert_eq!(ev.created_at, 1_700_000_000); + assert_eq!(ev.kind, 0); + assert_eq!(ev.tags_json, "[]"); + assert!( + ev.content.contains(&distinctive_pk0()), + "content must be forwarded byte-for-byte (no redact on the wire)" + ); + assert_eq!(ev.sig, vec![0x93; 64]); + } + + #[test] + fn delivery_position_binding_two_outputs() { + // Each delivery stays bound to output_templates[i] — never re-keyed. + let parsed: TransitionRequestJson = + serde_json::from_value(send_two_outputs_with_deliveries()).expect("parse"); + let req = json_to_transition(parsed).expect("convert"); + assert_eq!(req.output_templates.len(), 2); + + let inv0 = match req.output_templates[0] + .delivery + .as_ref() + .unwrap() + .body + .as_ref() + .unwrap() + { + DeliveryBody::Invoice(i) => i, + _ => panic!("[0] invoice"), + }; + let inv1 = match req.output_templates[1] + .delivery + .as_ref() + .unwrap() + .body + .as_ref() + .unwrap() + { + DeliveryBody::Invoice(i) => i, + _ => panic!("[1] invoice"), + }; + assert_eq!(inv0.amount, "100"); + assert_eq!(inv0.memo, distinctive_memo()); + assert_eq!(inv0.pk0, decode_hex_exact(&distinctive_pk0(), 32).unwrap()); + assert_eq!(inv1.amount, "200"); + assert_eq!(inv1.memo, "second-output-memo"); + assert_eq!(inv1.pk0, vec![0xAB; 32]); + // Positions must not swap. + assert_ne!(inv0.pk0, inv1.pk0); + assert_eq!(req.output_templates[0].amount, "100"); + assert_eq!(req.output_templates[1].amount, "200"); + } + + #[test] + fn invoice_memo_absent_vs_empty_both_map_without_trim() { + // §1.5: absent memo → empty proto string (normalisation, not free pass-through). + let mut v = mint_with_invoice_delivery(); + v["output_templates"][0]["delivery"]["invoice"] + .as_object_mut() + .unwrap() + .remove("memo"); + let req = json_to_transition(serde_json::from_value(v).unwrap()).unwrap(); + let inv = match req.output_templates[0] + .delivery + .as_ref() + .unwrap() + .body + .as_ref() + .unwrap() + { + DeliveryBody::Invoice(i) => i, + _ => panic!("invoice"), + }; + assert_eq!(inv.memo, ""); + + // Present empty string also → empty (same §1.5 contribution). + let mut v_empty = mint_with_invoice_delivery(); + v_empty["output_templates"][0]["delivery"]["invoice"]["memo"] = serde_json::json!(""); + let req_empty = json_to_transition(serde_json::from_value(v_empty).unwrap()).unwrap(); + let inv_empty = match req_empty.output_templates[0] + .delivery + .as_ref() + .unwrap() + .body + .as_ref() + .unwrap() + { + DeliveryBody::Invoice(i) => i, + _ => panic!("invoice"), + }; + assert_eq!(inv_empty.memo, ""); + + // Present memo with leading/trailing spaces is NOT trimmed. + let mut v2 = mint_with_invoice_delivery(); + v2["output_templates"][0]["delivery"]["invoice"]["memo"] = + serde_json::json!(" spaced memo "); + let req2 = json_to_transition(serde_json::from_value(v2).unwrap()).unwrap(); + let inv2 = match req2.output_templates[0] + .delivery + .as_ref() + .unwrap() + .body + .as_ref() + .unwrap() + { + DeliveryBody::Invoice(i) => i, + _ => panic!("invoice"), + }; + assert_eq!(inv2.memo, " spaced memo "); + } + + fn sample_job(status: &str) -> Job { + Job { + job_id: "j1".into(), + kind: "mint".into(), + status: status.into(), + phase: String::new(), + progress: 0.0, + awaiting_signature: None, + result: None, + error: None, + } + } + + #[test] + fn validate_job_rejects_unknown_status() { + let job = sample_job("totally_unknown_phase"); + let err = validate_job(&job).expect_err("unknown status"); + assert_eq!(err.body.error, "internal_error"); + assert!( + err.cause().unwrap_or("").contains("totally_unknown_phase") + || err.cause().unwrap_or("").contains("closed"), + "cause must name the foreign status, got {:?}", + err.cause() + ); + } + + #[test] + fn validate_job_rejects_unknown_terminal_error_code() { + let mut job = sample_job("failed"); + job.error = Some(crate::kernel::kernel_v1::JobError { + error: "not_a_real_job_error".into(), + message: "x".into(), + }); + let err = validate_job(&job).expect_err("foreign error code"); + assert_eq!(err.body.error, "internal_error"); + assert!( + err.cause().unwrap_or("").contains("not_a_real_job_error") + || err.cause().unwrap_or("").contains("closed"), + "cause must name the foreign code, got {:?}", + err.cause() + ); + } + + /// Node finalise path stores typed `DependencyNotFinal` as terminal + /// `JobError.error = "dependency_not_final"`. Poll and SSE must project + /// that code, not fail-closed as `500 internal_error`. + #[test] + fn validate_job_and_poll_accept_dependency_not_final() { + let mut job = sample_job("failed"); + job.error = Some(crate::kernel::kernel_v1::JobError { + error: "dependency_not_final".into(), + message: "predecessor nullifier not covered by size_final".into(), + }); + validate_job(&job).expect("dependency_not_final is a closed terminal code"); + assert!( + validate_sse_event_status("error", &job).is_ok(), + "SSE error event must accept dependency_not_final terminal job" + ); + let json = job_to_json(&job).expect("poll projection"); + assert_eq!(json["status"], "failed"); + assert_eq!(json["error"]["error"], "dependency_not_final"); + assert_eq!( + json["error"]["message"], + "predecessor nullifier not covered by size_final" + ); + } + + #[test] + fn validate_job_enforces_status_payload_exclusivity() { + // completed without result + let job = sample_job("completed"); + assert!(validate_job(&job).is_err()); + + // accepted with error payload + let mut job = sample_job("accepted"); + job.error = Some(crate::kernel::kernel_v1::JobError { + error: "proving_failed".into(), + message: "x".into(), + }); + assert!(validate_job(&job).is_err()); + + // failed without error + let job = sample_job("failed"); + assert!(validate_job(&job).is_err()); + } + + #[test] + fn validate_job_rejects_terminal_nonempty_phase() { + let mut job = sample_job("completed"); + job.phase = "publishing".into(); + job.result = Some(crate::kernel::kernel_v1::JobResult { + new_account_state_hash: vec![0x11; 32], + output_coins_root: vec![0x22; 32], + input_nullifiers_root: vec![0x33; 32], + output_coin_ids: vec![], + publisher_pubkey: vec![], + attestation: vec![], + }); + let err = validate_job(&job).expect_err("terminal phase must fail"); + assert_eq!(err.body.error, "internal_error"); + assert!( + err.cause().unwrap_or("").contains("phase"), + "cause must name phase, got {:?}", + err.cause() + ); + } + + #[test] + fn validate_job_attest_requires_attestation_rejects_transition_fields() { + let mut job = sample_job("completed"); + job.kind = "attest_balance".into(); + // Empty result → fail. + job.result = Some(crate::kernel::kernel_v1::JobResult { + new_account_state_hash: vec![], + output_coins_root: vec![], + input_nullifiers_root: vec![], + output_coin_ids: vec![], + publisher_pubkey: vec![], + attestation: vec![], + }); + assert!(validate_job(&job).is_err()); + + // Attestation + transition digest → fail. + job.result = Some(crate::kernel::kernel_v1::JobResult { + new_account_state_hash: vec![0x11; 32], + output_coins_root: vec![], + input_nullifiers_root: vec![], + output_coin_ids: vec![], + publisher_pubkey: vec![], + attestation: vec![0xaa, 0xbb], + }); + assert!(validate_job(&job).is_err()); + + // Pure attestation → ok. + job.result = Some(crate::kernel::kernel_v1::JobResult { + new_account_state_hash: vec![], + output_coins_root: vec![], + input_nullifiers_root: vec![], + output_coin_ids: vec![], + publisher_pubkey: vec![], + attestation: vec![0xaa, 0xbb, 0xcc], + }); + assert!(validate_job(&job).is_ok()); + } + + #[test] + fn validate_job_transition_rejects_attestation() { + let mut job = sample_job("completed"); + job.result = Some(crate::kernel::kernel_v1::JobResult { + new_account_state_hash: vec![0x11; 32], + output_coins_root: vec![0x22; 32], + input_nullifiers_root: vec![0x33; 32], + output_coin_ids: vec![], + publisher_pubkey: vec![], + attestation: vec![0xaa], + }); + let err = validate_job(&job).expect_err("attestation on mint"); + assert_eq!(err.body.error, "internal_error"); + } + + #[test] + fn job_to_json_neutralises_internal_error_message() { + const SECRET: &str = "enqueue failed: /var/lib/SECRET_PATH_do_not_leak"; + let mut job = sample_job("failed"); + job.error = Some(crate::kernel::kernel_v1::JobError { + error: "internal_error".into(), + message: SECRET.into(), + }); + let json = job_to_json(&job).expect("project"); + assert_eq!(json["error"]["error"], "internal_error"); + assert_eq!( + json["error"]["message"], + crate::error::PUBLIC_INTERNAL_MESSAGE + ); + let wire = json.to_string(); + assert!( + !wire.contains("SECRET_PATH"), + "public JSON must not leak secret: {wire}" + ); + assert!(!wire.contains("enqueue failed")); + } + + #[test] + fn validate_sse_event_status_correlation() { + let mut proving = sample_job("proving"); + proving.phase = "witness".into(); + assert!(validate_sse_event_status("phase", &proving).is_ok()); + + // phase + terminal status is a contract breach. + let mut completed = sample_job("completed"); + completed.result = Some(crate::kernel::kernel_v1::JobResult { + new_account_state_hash: vec![0x11; 32], + output_coins_root: vec![0x22; 32], + input_nullifiers_root: vec![0x33; 32], + output_coin_ids: vec![], + publisher_pubkey: vec![], + attestation: vec![], + }); + assert!(validate_sse_event_status("phase", &completed).is_err()); + assert!(validate_sse_event_status("complete", &completed).is_ok()); + + // complete with non-completed status + assert!(validate_sse_event_status("complete", &proving).is_err()); + + let mut failed = sample_job("failed"); + failed.error = Some(crate::kernel::kernel_v1::JobError { + error: "proving_failed".into(), + message: "x".into(), + }); + assert!(validate_sse_event_status("error", &failed).is_ok()); + assert!(validate_sse_event_status("error", &proving).is_err()); + } + + #[test] + fn unknown_delivery_type_is_malformed_at_json_edge() { + let mut v = mint_json(); + v["output_templates"][0]["delivery"] = serde_json::json!({ + "type": "carrier_pigeon", + "invoice": sample_invoice_json(), + }); + let err = serde_json::from_value::(v).expect_err("unknown type"); + let msg = err.to_string(); + assert!( + msg.contains("carrier_pigeon") + || msg.contains("unknown variant") + || msg.contains("did not match"), + "unknown type must fail serde, got {msg}" + ); + } + + #[test] + fn unknown_field_inside_invoice_is_malformed() { + let mut v = mint_with_invoice_delivery(); + v["output_templates"][0]["delivery"]["invoice"]["ghost_field"] = serde_json::json!("nope"); + let err = serde_json::from_value::(v).expect_err("deny"); + assert!( + err.to_string().contains("ghost_field") || err.to_string().contains("unknown field"), + "got {err}" + ); + } + + #[test] + fn unknown_field_inside_profile_event_is_malformed() { + let mut v = mint_with_profile_delivery(); + v["output_templates"][0]["delivery"]["event"]["extra"] = serde_json::json!(1); + let err = serde_json::from_value::(v).expect_err("deny"); + assert!( + err.to_string().contains("extra") || err.to_string().contains("unknown field"), + "got {err}" + ); + } + + #[test] + fn missing_invoice_required_field_is_malformed() { + let mut v = mint_with_invoice_delivery(); + v["output_templates"][0]["delivery"]["invoice"] + .as_object_mut() + .unwrap() + .remove("pk0"); + let err = serde_json::from_value::(v).expect_err("missing pk0"); + assert!( + err.to_string().contains("pk0") || err.to_string().contains("missing field"), + "got {err}" + ); + } + + #[test] + fn missing_profile_required_field_is_malformed() { + let mut v = mint_with_profile_delivery(); + v["output_templates"][0]["delivery"]["event"] + .as_object_mut() + .unwrap() + .remove("content"); + let err = serde_json::from_value::(v).expect_err("missing content"); + assert!( + err.to_string().contains("content") || err.to_string().contains("missing field"), + "got {err}" + ); + } + + #[test] + fn invoice_pk0_wrong_hex_width_is_malformed_without_echoing_value() { + let mut v = mint_with_invoice_delivery(); + // Distinctive wrong-length hex — must not leak into the error message. + let bad = "deadbeef".repeat(5); // 40 chars, not 64 + v["output_templates"][0]["delivery"]["invoice"]["pk0"] = serde_json::json!(bad.clone()); + let parsed: TransitionRequestJson = serde_json::from_value(v).expect("shape ok"); + let err = json_to_transition(parsed).expect_err("form"); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + assert_eq!(err.body.error, "malformed_request"); + assert!( + err.body.message.contains("pk0"), + "message must name the field path, got {}", + err.body.message + ); + assert!( + !err.body.message.contains(&bad), + "§7.5 retention: error must not echo pk0 hex, got {}", + err.body.message + ); + assert!( + !err.body.message.contains(&distinctive_memo()), + "error must not quote memo either, got {}", + err.body.message + ); + } + + /// Capture layer for the §7.5 retention rule: Debug of the parsed body + /// (what a logger that prints the extractor would see) must not contain + /// `pk0` hex or `memo` text. Same discipline as `BootstrapEntrustBody`. + #[test] + fn delivery_debug_and_error_paths_never_log_pk0_or_memo() { + let parsed: TransitionRequestJson = + serde_json::from_value(mint_with_invoice_delivery()).expect("parse"); + let dbg = format!("{parsed:?}"); + let pk0 = distinctive_pk0(); + let memo = distinctive_memo(); + assert!( + !dbg.contains(&pk0), + "Debug of TransitionRequestJson must redact pk0; got {dbg}" + ); + assert!( + !dbg.contains(&memo), + "Debug of TransitionRequestJson must redact memo; got {dbg}" + ); + // Arm name is allowed; credential contents are not. + assert!( + dbg.contains("redacted") || dbg.contains("Invoice"), + "Debug should still indicate a redacted delivery arm, got {dbg}" + ); + + // Profile content carries pk0 inside zkcoins JSON — also redacted. + let parsed_p: TransitionRequestJson = + serde_json::from_value(mint_with_profile_delivery()).expect("parse profile"); + let dbg_p = format!("{parsed_p:?}"); + assert!( + !dbg_p.contains(&pk0), + "profile Debug must redact content-held pk0; got {dbg_p}" + ); + + // Invoice-level Debug alone. + match &parsed.output_templates.as_ref().unwrap()[0].delivery { + Some(DeliveryCredentialJson::Invoice { invoice }) => { + let inv_dbg = format!("{invoice:?}"); + assert!(!inv_dbg.contains(&pk0)); + assert!(!inv_dbg.contains(&memo)); + } + other => panic!("expected invoice arm, got {other:?}"), + } + } + + #[test] + fn absent_delivery_stays_none_on_proto() { + // Self-output MAY omit delivery; API does not invent one. + let parsed: TransitionRequestJson = serde_json::from_value(mint_json()).expect("parse"); + let req = json_to_transition(parsed).expect("convert"); + assert!(req.output_templates[0].delivery.is_none()); + } + + #[test] + fn mint_must_not_carry_genesis_pubkey() { + let mut v = mint_json(); + v["genesis_pubkey"] = serde_json::json!(hex32(0xD0)); + let parsed: TransitionRequestJson = serde_json::from_value(v).expect("shape ok"); + let err = json_to_transition(parsed).expect_err("mint + genesis_pubkey"); + assert_eq!(err.body.error, "malformed_request"); + assert!( + err.body.message.contains("must not carry genesis_pubkey"), + "message must name the forbidden field, got {}", + err.body.message + ); + } + + #[test] + fn send_must_not_carry_genesis_pubkey() { + let mut v = send_two_outputs_with_deliveries(); + v["genesis_pubkey"] = serde_json::json!(hex32(0xD0)); + let parsed: TransitionRequestJson = serde_json::from_value(v).expect("shape ok"); + let err = json_to_transition(parsed).expect_err("send + genesis_pubkey"); + assert_eq!(err.body.error, "malformed_request"); + assert!( + err.body.message.contains("must not carry genesis_pubkey"), + "message must name the forbidden field, got {}", + err.body.message + ); + } + + #[test] + fn receive_with_genesis_pubkey_parses() { + let v = serde_json::json!({ + "kind": "receive", + "subject": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "next_pubkey": hex32(0x11), + "npk_rand": hex32(0x22), + "fold_coin_ids": [hex32(0x33)], + "genesis_pubkey": hex32(0xD0), + }); + let parsed: TransitionRequestJson = serde_json::from_value(v).expect("shape ok"); + let req = json_to_transition(parsed).expect("receive with genesis_pubkey"); + assert_eq!(req.kind, "receive"); + assert_eq!(req.genesis_pubkey, vec![0xD0u8; 32]); + } +} diff --git a/src/kernel/client.rs b/src/kernel/client.rs new file mode 100644 index 0000000..8702690 --- /dev/null +++ b/src/kernel/client.rs @@ -0,0 +1,488 @@ +//! Lazy gRPC client for `kernel.v1.Kernel`. +//! +//! Address comes from process config (`ZKCOINS_KERNEL_ADDR`); this module +//! never invents a host or port. Connection is lazy: a bad URI fails at +//! construction; an unreachable kernel surfaces as a transport error on the +//! first RPC (mapped separately from domain ErrorInfo). + +use crate::error::ApiError; +use crate::kernel::error_info::{kernel_status_to_api_error_for, KernelProcedure}; +use crate::kernel::pb::kernel_v1::kernel_client::KernelClient as TonicKernelClient; +use crate::kernel::pb::kernel_v1::{ + AccountStateRequest, AccountStateResult, AccumulatorTip, AttestRequest, Challenge, + CoinProofBlob, CoinProofRequest, EntrustRequest, EntrustResult, GetAccumulatorRequest, + GetInfoRequest, GrantRequest, GrantResult, Info, Inscription, Job, JobEvent, JobHandle, + JobRequest, ListInscriptionsRequest, NullifierPath, NullifierPathRequest, PublishRequest, + PublishResult, PullChallengeRequest, PullRequest, PullResult, Receipt, RecordBlob, + RecordRequest, RevokeRequest, RevokeResult, SignRequest, SubscribeReceiptsRequest, + TransitionRequest, +}; +use crate::ownership::SessionAuthority; +use async_trait::async_trait; +use futures_util::stream::BoxStream; +use futures_util::StreamExt; +use std::sync::Arc; +use tonic::metadata::MetadataValue; +use tonic::transport::Channel; +use tonic::Request; + +/// Interim metadata key the node reads for pull session authority +/// (`node/src/kernel_rpc.rs`). Missing ⇒ kernel `malformed_request` (never +/// silent Ownership). +const SESSION_AUTHORITY_METADATA: &str = "x-zkcoins-session-authority"; + +/// Subset of kernel procedures this stage consumes +/// (job surface + info/chain + attest/grants + pull/records + receipts stream +/// + bootstrap + publish). +#[async_trait] +pub trait KernelRpc: Send + Sync { + async fn submit_transition(&self, req: TransitionRequest) -> Result; + + async fn get_job(&self, req: JobRequest) -> Result; + + async fn stream_job( + &self, + req: JobRequest, + ) -> Result>, ApiError>; + + async fn sign_transition(&self, req: SignRequest) -> Result; + + async fn cancel_job(&self, req: JobRequest) -> Result; + + async fn get_info(&self) -> Result; + + async fn get_accumulator(&self) -> Result; + + /// Server-stream of inscriptions from an inclusive triple cursor (§7.8). + /// The REST handler collects the stream into one page. + async fn list_inscriptions( + &self, + req: ListInscriptionsRequest, + ) -> Result>, ApiError>; + + async fn get_nullifier_path( + &self, + req: NullifierPathRequest, + ) -> Result; + + async fn open_pull_challenge(&self, req: PullChallengeRequest) -> Result; + + async fn attest_balance(&self, req: AttestRequest) -> Result; + + async fn issue_view_grant(&self, req: GrantRequest) -> Result; + + /// `Pull` with session authority metadata (never omitted, never defaulted). + async fn pull( + &self, + req: PullRequest, + authority: SessionAuthority, + ) -> Result; + + async fn get_record(&self, req: RecordRequest) -> Result; + + async fn get_coin_proof(&self, req: CoinProofRequest) -> Result; + + async fn get_account_state( + &self, + req: AccountStateRequest, + ) -> Result; + + /// Server-stream of verified receipts for a pull session (§7.8 / §4.9). + /// Handshake failures (unknown session, `chan_bind` mismatch, transport) + /// return `Err` before any frame; the REST handler maps those to the + /// pre-SSE HTTP status. Mid-stream breaks become `Err` items. + async fn subscribe_receipts( + &self, + req: SubscribeReceiptsRequest, + ) -> Result>, ApiError>; + + async fn entrust_operational_bundle( + &self, + req: EntrustRequest, + ) -> Result; + + async fn revoke_operational_bundle(&self, req: RevokeRequest) + -> Result; + + /// `Publish` — hand-off outcome is a successful result even when rejected. + async fn publish(&self, req: PublishRequest) -> Result; +} + +/// Shared handle installed in the axum `State`. +pub type KernelHandle = Arc; + +/// Production client over a tonic channel. +#[derive(Clone, Debug)] +pub struct KernelClient { + inner: TonicKernelClient, +} + +impl KernelClient { + /// Build a lazy channel to `kernel_addr`. + /// + /// `kernel_addr` must already be non-empty (enforced by [`crate::Config`]). + /// An unparseable URI is a construction error — the process must not start + /// with a nonsense target. + /// + /// # Tokio runtime required + /// + /// Even though the TCP dial is deferred until the first RPC, tonic's + /// `Endpoint::connect_lazy` still spawns a channel worker on the current + /// Tokio executor (`Buffer::pair` + `executor.execute`). Calling this + /// **outside** a running Tokio 1.x runtime panics (`there is no reactor + /// running`). Production entry (`#[tokio::main]`) and tests that build a + /// client must already be on a runtime; URI/emptiness checks above run + /// first and do not need one. + pub fn connect_lazy(kernel_addr: &str) -> Result { + if kernel_addr.is_empty() { + return Err(ClientBuildError::EmptyAddr); + } + let channel = tonic::transport::Endpoint::from_shared(kernel_addr.to_string()) + .map_err(|e| ClientBuildError::InvalidUri { + value: kernel_addr.to_string(), + reason: e.to_string(), + })? + .connect_lazy(); + Ok(Self { + inner: TonicKernelClient::new(channel), + }) + } +} + +/// Failures that prevent constructing a client (start-time, not transport). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientBuildError { + EmptyAddr, + InvalidUri { value: String, reason: String }, +} + +impl std::fmt::Display for ClientBuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientBuildError::EmptyAddr => { + write!(f, "kernel address is empty") + } + ClientBuildError::InvalidUri { value, reason } => { + write!( + f, + "ZKCOINS_KERNEL_ADDR value {value:?} is not a valid gRPC endpoint URI: {reason}" + ) + } + } + } +} + +impl std::error::Error for ClientBuildError {} + +/// Construct a [`KernelClient`] or return a named build error. +pub fn connect_lazy(kernel_addr: &str) -> Result { + KernelClient::connect_lazy(kernel_addr) +} + +#[async_trait] +impl KernelRpc for KernelClient { + async fn submit_transition(&self, req: TransitionRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .submit_transition(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::SubmitTransition))?; + Ok(response.into_inner()) + } + + async fn get_job(&self, req: JobRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .get_job(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::GetJob))?; + Ok(response.into_inner()) + } + + async fn stream_job( + &self, + req: JobRequest, + ) -> Result>, ApiError> { + let mut client = self.inner.clone(); + let response = client + .stream_job(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::StreamJob))?; + let stream = response.into_inner().map(|item| match item { + Ok(ev) => Ok(ev), + Err(status) => Err(kernel_status_to_api_error_for( + &status, + Some(KernelProcedure::StreamJob), + )), + }); + Ok(Box::pin(stream)) + } + + async fn sign_transition(&self, req: SignRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .sign_transition(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::SignTransition))?; + Ok(response.into_inner()) + } + + async fn cancel_job(&self, req: JobRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .cancel_job(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::CancelJob))?; + Ok(response.into_inner()) + } + + async fn get_info(&self) -> Result { + let mut client = self.inner.clone(); + let response = client + .get_info(Request::new(GetInfoRequest {})) + .await + .map_err(map_for(KernelProcedure::GetInfo))?; + Ok(response.into_inner()) + } + + async fn get_accumulator(&self) -> Result { + let mut client = self.inner.clone(); + let response = client + .get_accumulator(Request::new(GetAccumulatorRequest {})) + .await + .map_err(map_for(KernelProcedure::GetAccumulator))?; + Ok(response.into_inner()) + } + + async fn list_inscriptions( + &self, + req: ListInscriptionsRequest, + ) -> Result>, ApiError> { + let mut client = self.inner.clone(); + let response = client + .list_inscriptions(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::ListInscriptions))?; + let stream = response.into_inner().map(|item| match item { + Ok(ins) => Ok(ins), + Err(status) => Err(kernel_status_to_api_error_for( + &status, + Some(KernelProcedure::ListInscriptions), + )), + }); + Ok(Box::pin(stream)) + } + + async fn get_nullifier_path( + &self, + req: NullifierPathRequest, + ) -> Result { + let mut client = self.inner.clone(); + let response = client + .get_nullifier_path(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::GetNullifierPath))?; + Ok(response.into_inner()) + } + + async fn open_pull_challenge(&self, req: PullChallengeRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .open_pull_challenge(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::OpenPullChallenge))?; + Ok(response.into_inner()) + } + + async fn attest_balance(&self, req: AttestRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .attest_balance(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::AttestBalance))?; + Ok(response.into_inner()) + } + + async fn issue_view_grant(&self, req: GrantRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .issue_view_grant(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::IssueViewGrant))?; + Ok(response.into_inner()) + } + + async fn pull( + &self, + req: PullRequest, + authority: SessionAuthority, + ) -> Result { + let mut client = self.inner.clone(); + let mut request = Request::new(req); + // Fail-closed: authority is always set from the verified proof kind. + // The node rejects a missing key as malformed_request (never Ownership). + // `as_str` is a closed `'static` token (`ownership` | `grant`). + request.metadata_mut().insert( + SESSION_AUTHORITY_METADATA, + MetadataValue::from_static(authority.as_str()), + ); + let response = client + .pull(request) + .await + .map_err(map_for(KernelProcedure::Pull))?; + Ok(response.into_inner()) + } + + async fn get_record(&self, req: RecordRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .get_record(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::GetRecord))?; + Ok(response.into_inner()) + } + + async fn get_coin_proof(&self, req: CoinProofRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .get_coin_proof(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::GetCoinProof))?; + Ok(response.into_inner()) + } + + async fn get_account_state( + &self, + req: AccountStateRequest, + ) -> Result { + let mut client = self.inner.clone(); + let response = client + .get_account_state(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::GetAccountState))?; + Ok(response.into_inner()) + } + + async fn subscribe_receipts( + &self, + req: SubscribeReceiptsRequest, + ) -> Result>, ApiError> { + let mut client = self.inner.clone(); + let response = client + .subscribe_receipts(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::SubscribeReceipts))?; + let stream = response.into_inner().map(|item| match item { + Ok(receipt) => Ok(receipt), + Err(status) => Err(kernel_status_to_api_error_for( + &status, + Some(KernelProcedure::SubscribeReceipts), + )), + }); + Ok(Box::pin(stream)) + } + + async fn entrust_operational_bundle( + &self, + req: EntrustRequest, + ) -> Result { + let mut client = self.inner.clone(); + let response = client + .entrust_operational_bundle(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::EntrustOperationalBundle))?; + Ok(response.into_inner()) + } + + async fn revoke_operational_bundle( + &self, + req: RevokeRequest, + ) -> Result { + let mut client = self.inner.clone(); + let response = client + .revoke_operational_bundle(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::RevokeOperationalBundle))?; + Ok(response.into_inner()) + } + + async fn publish(&self, req: PublishRequest) -> Result { + let mut client = self.inner.clone(); + let response = client + .publish(Request::new(req)) + .await + .map_err(map_for(KernelProcedure::Publish))?; + Ok(response.into_inner()) + } +} + +/// Map a tonic `Status` to REST for a known kernel procedure. +/// +/// Domain failures carry `ErrorInfo` and become the §7.5 body via +/// [`kernel_status_to_api_error_for`]. Transport failures (unreachable kernel, +/// reset connection) arrive as a `Status` **without** usable ErrorInfo after +/// tonic converts the underlying `transport::Error`; that path is also +/// fail-closed to `500 internal_error` (no guessed machine code). The +/// dedicated [`super::transport_error_to_api_error`] helper documents the same +/// outcome for call sites that still hold a raw `transport::Error` — this +/// client never holds that type under `connect_lazy`. +fn map_for(procedure: KernelProcedure) -> impl FnOnce(tonic::Status) -> ApiError { + move |status| kernel_status_to_api_error_for(&status, Some(procedure)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_addr_is_build_error() { + let err = KernelClient::connect_lazy("").expect_err("empty"); + assert_eq!(err, ClientBuildError::EmptyAddr); + } + + #[test] + fn invalid_uri_is_named() { + let err = KernelClient::connect_lazy("not a uri").expect_err("bad uri"); + match err { + ClientBuildError::InvalidUri { value, reason } => { + assert_eq!(value, "not a uri"); + assert!(!reason.is_empty()); + } + other => panic!("expected InvalidUri, got {other:?}"), + } + } + + #[test] + fn valid_http_uri_builds_lazy_client() { + // Statement under test is still construction-only (no RPC). The + // Tokio runtime context is required by tonic's lazy channel worker + // spawn — see [`KernelClient::connect_lazy`]. + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime"); + let _enter = rt.enter(); + KernelClient::connect_lazy("http://127.0.0.1:50051").expect("valid"); + } + + #[test] + fn transport_error_helper_names_cause() { + // Production RPCs never hold a raw `tonic::transport::Error`: with + // `connect_lazy`, dial failures surface as `tonic::Status` and go + // through `map_status` → `kernel_status_to_api_error` (fail-closed + // 500). This helper is the named path for call sites that still hold + // the raw transport error; pin its contract via the kernel facade. + use crate::kernel::transport_error_to_api_error; + let f: fn(&tonic::transport::Error) -> ApiError = transport_error_to_api_error; + let _ = f; + let err = ApiError::internal("kernel transport error: connection refused"); + assert_eq!(err.status, axum::http::StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, crate::error::PUBLIC_INTERNAL_MESSAGE); + assert!( + err.cause().unwrap_or("").contains("kernel transport error"), + "operator cause must name transport class, got {:?}", + err.cause() + ); + } +} diff --git a/src/kernel/error_info.rs b/src/kernel/error_info.rs new file mode 100644 index 0000000..947ab89 --- /dev/null +++ b/src/kernel/error_info.rs @@ -0,0 +1,870 @@ +//! Translate `tonic::Status` + `google.rpc.ErrorInfo` → §7.5 REST errors. +//! +//! **Wire shape (production):** the node packs errors via `tonic-types` +//! (`Status::with_error_details`) so `Status.details` is a `google.rpc.Status` +//! envelope whose `details` array holds **exactly one** `google.rpc.ErrorInfo`. +//! This module decodes that envelope only — bare `Any` / raw `ErrorInfo` are +//! rejected (fail-closed). +//! +//! **Single source of HTTP status:** `ErrorInfo.metadata["http_status"]` from +//! the kernel, validated against the closed `(gRPC code, reason, http_status)` +//! table. Anything else is `500 internal_error` with a neutral public message. + +use crate::error::ApiError; +use axum::http::StatusCode; +use std::collections::HashMap; +use tonic::Code; +use tonic::Status; +#[cfg(test)] +use tonic_types::ErrorDetails; +use tonic_types::{ErrorDetail, StatusExt}; + +/// Normative `ErrorInfo.domain` (§7.8). +pub const ERROR_INFO_DOMAIN: &str = "kernel.v1"; + +/// One closed RPC-level `(reason, http_status, gRPC Code)` triple from +/// node `error_contract::describe` / Spec §7.8. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RpcErrorTriple { + reason: &'static str, + http_status: u16, + grpc: Code, +} + +/// Closed set of triples a kernel procedure **MAY** emit as gRPC `Status` +/// failures. Job-payload-only codes (`proving_failed`, `publish_rejected`) and +/// API-only codes (`feature_disabled`) are **not** listed — if they appear in +/// `ErrorInfo` they are protocol violations → 500. +const RPC_ERROR_TRIPLES: &[RpcErrorTriple] = &[ + RpcErrorTriple { + reason: "malformed_request", + http_status: 400, + grpc: Code::InvalidArgument, + }, + RpcErrorTriple { + reason: "bounds_exceeded", + http_status: 400, + grpc: Code::InvalidArgument, + }, + RpcErrorTriple { + reason: "invalid_input_coin", + http_status: 400, + grpc: Code::InvalidArgument, + }, + RpcErrorTriple { + reason: "insufficient_balance", + http_status: 400, + grpc: Code::InvalidArgument, + }, + RpcErrorTriple { + reason: "unknown_publisher", + http_status: 400, + grpc: Code::InvalidArgument, + }, + RpcErrorTriple { + reason: "job_not_found", + http_status: 404, + grpc: Code::NotFound, + }, + RpcErrorTriple { + reason: "not_found", + http_status: 404, + grpc: Code::NotFound, + }, + RpcErrorTriple { + reason: "wrong_phase", + http_status: 409, + grpc: Code::FailedPrecondition, + }, + RpcErrorTriple { + reason: "stale_message", + http_status: 409, + grpc: Code::FailedPrecondition, + }, + RpcErrorTriple { + reason: "invalid_signature", + http_status: 409, + grpc: Code::FailedPrecondition, + }, + // `retention_hold` removed with data permanence (Requirement 12): the + // Blossom store is append-only; there is no DELETE refusal path. + RpcErrorTriple { + reason: "dependency_not_final", + http_status: 409, + grpc: Code::FailedPrecondition, + }, + RpcErrorTriple { + reason: "idempotency_conflict", + http_status: 409, + grpc: Code::FailedPrecondition, + }, + RpcErrorTriple { + reason: "unauthorized", + http_status: 401, + grpc: Code::Unauthenticated, + }, + // 410 special cases: same gRPC class as unauthorized, distinct HTTP. + RpcErrorTriple { + reason: "challenge_expired", + http_status: 410, + grpc: Code::Unauthenticated, + }, + RpcErrorTriple { + reason: "session_expired", + http_status: 410, + grpc: Code::Unauthenticated, + }, + RpcErrorTriple { + reason: "scope_exceeded", + http_status: 403, + grpc: Code::PermissionDenied, + }, + RpcErrorTriple { + reason: "rate_limited", + http_status: 429, + grpc: Code::ResourceExhausted, + }, + RpcErrorTriple { + reason: "payload_too_large", + http_status: 413, + grpc: Code::ResourceExhausted, + }, + RpcErrorTriple { + reason: "circuit_digest_mismatch", + http_status: 503, + grpc: Code::Unavailable, + }, + RpcErrorTriple { + reason: "internal_error", + http_status: 500, + grpc: Code::Internal, + }, +]; + +/// Kernel procedure names for per-RPC allowed-error sets (§7.8 table). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KernelProcedure { + GetInfo, + GetAccumulator, + ListInscriptions, + GetNullifierPath, + SubmitTransition, + GetJob, + StreamJob, + SignTransition, + CancelJob, + OpenPullChallenge, + Pull, + GetRecord, + GetCoinProof, + GetAccountState, + SubscribeReceipts, + Publish, + EntrustOperationalBundle, + RevokeOperationalBundle, + AttestBalance, + IssueViewGrant, +} + +impl KernelProcedure { + /// Reasons this procedure **MAY** emit (always includes `internal_error` + /// and, where the Spec allows not-ready as generic 503, that is folded + /// into `internal_error` or procedure-specific codes only). + fn allowed_reasons(self) -> &'static [&'static str] { + match self { + Self::GetInfo | Self::GetAccumulator => &["internal_error"], + Self::ListInscriptions => &[ + "bounds_exceeded", + "malformed_request", + "rate_limited", + "internal_error", + ], + Self::GetNullifierPath => &["malformed_request", "rate_limited", "internal_error"], + Self::SubmitTransition => &[ + "malformed_request", + "bounds_exceeded", + "invalid_input_coin", + "insufficient_balance", + "unknown_publisher", + "idempotency_conflict", + "dependency_not_final", + "rate_limited", + "circuit_digest_mismatch", + "internal_error", + ], + Self::GetJob | Self::StreamJob => &[ + "malformed_request", + "job_not_found", + "rate_limited", + "internal_error", + ], + Self::SignTransition => &[ + "malformed_request", + "job_not_found", + "wrong_phase", + "stale_message", + "invalid_signature", + "rate_limited", + "internal_error", + ], + Self::CancelJob => &[ + "malformed_request", + "job_not_found", + "wrong_phase", + "rate_limited", + "internal_error", + ], + Self::OpenPullChallenge => &["malformed_request", "rate_limited", "internal_error"], + Self::Pull => &[ + "malformed_request", + "unauthorized", + "challenge_expired", + "scope_exceeded", + "rate_limited", + "internal_error", + ], + Self::GetRecord | Self::GetCoinProof => &[ + "malformed_request", + "not_found", + "unauthorized", + "session_expired", + "scope_exceeded", + "rate_limited", + "internal_error", + ], + Self::GetAccountState => &[ + "malformed_request", + "unauthorized", + "session_expired", + "rate_limited", + "internal_error", + ], + Self::SubscribeReceipts => &[ + "malformed_request", + "unauthorized", + "session_expired", + "scope_exceeded", + "rate_limited", + "internal_error", + ], + Self::Publish => &["malformed_request", "rate_limited", "internal_error"], + Self::EntrustOperationalBundle | Self::RevokeOperationalBundle => &[ + "malformed_request", + "unauthorized", + "challenge_expired", + "rate_limited", + "internal_error", + ], + Self::AttestBalance => &[ + "malformed_request", + "unauthorized", + "challenge_expired", + "rate_limited", + "circuit_digest_mismatch", + "internal_error", + ], + Self::IssueViewGrant => &[ + "malformed_request", + "unauthorized", + "challenge_expired", + "rate_limited", + "internal_error", + ], + } + } +} + +fn lookup_triple(reason: &str) -> Option<&'static RpcErrorTriple> { + RPC_ERROR_TRIPLES.iter().find(|t| t.reason == reason) +} + +/// Decoded ErrorInfo fields used after tonic-types unpack. +struct DecodedErrorInfo { + reason: String, + domain: String, + metadata: HashMap, +} + +/// Map a failed kernel RPC `Status` to the §7.5 REST error (no procedure filter). +/// +/// Prefer [`kernel_status_to_api_error_for`] at production call sites so +/// procedure-foreign reasons fail closed. +pub fn kernel_status_to_api_error(status: &Status) -> ApiError { + kernel_status_to_api_error_for(status, None) +} + +/// Map a failed kernel RPC `Status`, optionally restricting to the procedure's +/// allowed reason set (§7.8 per-procedure table). +pub fn kernel_status_to_api_error_for( + status: &Status, + procedure: Option, +) -> ApiError { + match decode_error_info(status) { + Ok(info) => match validate_and_build(info, status, procedure) { + Ok(err) => err, + Err(why) => ApiError::internal(format!( + "kernel ErrorInfo failed contract validation: {why}" + )), + }, + Err(why) => ApiError::internal(format!("kernel status missing usable ErrorInfo: {why}")), + } +} + +/// A transport failure (dial, broken pipe, timeout) is **not** a kernel +/// domain error. §7.5 closes unlisted conditions as `internal_error` / 500. +pub fn transport_error_to_api_error(err: &tonic::transport::Error) -> ApiError { + ApiError::internal(format!("kernel transport error: {err}")) +} + +fn validate_and_build( + info: DecodedErrorInfo, + status: &Status, + procedure: Option, +) -> Result { + if info.domain != ERROR_INFO_DOMAIN { + return Err(format!( + "domain must be {ERROR_INFO_DOMAIN:?}, got {:?}", + info.domain + )); + } + if info.reason.is_empty() { + return Err("reason is empty".to_string()); + } + + // Job-payload-only / API-only codes must never arrive as RPC ErrorInfo. + if matches!( + info.reason.as_str(), + "proving_failed" | "publish_rejected" | "feature_disabled" + ) { + return Err(format!( + "reason {:?} is not an RPC ErrorInfo code (job-payload-only or API-only)", + info.reason + )); + } + + let triple = match lookup_triple(&info.reason) { + Some(t) => t, + None => { + return Err(format!( + "reason is not a closed §7.5 RPC machine_code: {:?}", + info.reason + )); + } + }; + + let http_raw = match info.metadata.get("http_status") { + Some(v) => v.as_str(), + None => return Err("metadata[\"http_status\"] is absent".to_string()), + }; + if http_raw.is_empty() { + return Err("metadata[\"http_status\"] is empty".to_string()); + } + let code_u16: u16 = match http_raw.parse::() { + Ok(n) => n, + Err(_) => { + return Err(format!( + "metadata[\"http_status\"] is not a u16 decimal: {http_raw:?}" + )); + } + }; + if http_raw != code_u16.to_string() { + return Err(format!( + "metadata[\"http_status\"] is not canonical decimal: {http_raw:?}" + )); + } + + // Full triple: reason ↔ http_status ↔ gRPC code. + if code_u16 != triple.http_status { + return Err(format!( + "reason {:?} requires http_status {}, got {code_u16}", + info.reason, triple.http_status + )); + } + if status.code() != triple.grpc { + return Err(format!( + "reason {:?} requires gRPC {:?}, got {:?}", + info.reason, + triple.grpc, + status.code() + )); + } + + if let Some(proc) = procedure { + if !proc.allowed_reasons().contains(&info.reason.as_str()) { + return Err(format!( + "reason {:?} is not allowed for procedure {:?}", + info.reason, proc + )); + } + } + + let http_status = match StatusCode::from_u16(code_u16) { + Ok(s) => s, + Err(_) => { + return Err(format!( + "metadata[\"http_status\"] is not a valid HTTP status: {code_u16}" + )); + } + }; + + // internal_error / 500: never forward kernel diagnostics onto the wire. + if info.reason == "internal_error" { + let cause = if status.message().is_empty() { + "kernel internal_error".to_string() + } else { + status.message().to_string() + }; + return Ok(ApiError::internal(cause)); + } + + let message = if status.message().is_empty() { + info.reason.clone() + } else { + status.message().to_string() + }; + Ok(ApiError::new(http_status, info.reason, message)) +} + +/// Decode exactly one `google.rpc.ErrorInfo` from the production +/// `google.rpc.Status` details envelope (`tonic-types`). No bare-Any or +/// raw-ErrorInfo fallback. +fn decode_error_info(status: &Status) -> Result { + let details = status.details(); + if details.is_empty() { + return Err("Status.details is empty".to_string()); + } + + let vec = status + .check_error_details_vec() + .map_err(|e| format!("google.rpc.Status details decode failed: {e}"))?; + + if vec.is_empty() { + return Err("google.rpc.Status.details has zero entries".to_string()); + } + if vec.len() != 1 { + return Err(format!( + "google.rpc.Status.details must hold exactly one ErrorInfo, got {} entries", + vec.len() + )); + } + + match &vec[0] { + ErrorDetail::ErrorInfo(info) => Ok(DecodedErrorInfo { + reason: info.reason.clone(), + domain: info.domain.clone(), + metadata: info.metadata.clone(), + }), + other => Err(format!( + "sole google.rpc.Status.details entry must be ErrorInfo, got {other:?}" + )), + } +} + +/// Build a `tonic::Status` carrying normative ErrorInfo (test double / helpers). +/// +/// Uses the **same** production encoder as the node (`tonic_types::StatusExt:: +/// with_error_details`) so tests exercise the real wire shape. Not compiled +/// into non-test builds: production never encodes kernel errors. +#[cfg(test)] +pub fn encode_kernel_error_status( + grpc_code: Code, + message: impl Into, + reason: impl Into, + http_status: u16, +) -> Status { + let reason = reason.into(); + let mut metadata = HashMap::new(); + metadata.insert("http_status".to_string(), http_status.to_string()); + let details = ErrorDetails::with_error_info(reason, ERROR_INFO_DOMAIN, metadata); + Status::with_error_details(grpc_code, message, details) +} + +/// Minimal ErrorInfo mirror for tests that still inspect field layout. +#[cfg(test)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ErrorInfo { + pub reason: String, + pub domain: String, + pub metadata: HashMap, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::PUBLIC_INTERNAL_MESSAGE; + use prost::Message; + use tonic_types::ErrorDetails; + + #[test] + fn maps_job_not_found_from_error_info() { + let st = encode_kernel_error_status(Code::NotFound, "Job not found", "job_not_found", 404); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::NOT_FOUND); + assert_eq!(err.body.error, "job_not_found"); + assert_eq!(err.body.message, "Job not found"); + } + + #[test] + fn maps_wrong_phase_from_error_info() { + let st = + encode_kernel_error_status(Code::FailedPrecondition, "wrong phase", "wrong_phase", 409); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::CONFLICT); + assert_eq!(err.body.error, "wrong_phase"); + } + + #[test] + fn maps_bounds_exceeded_from_error_info() { + let st = encode_kernel_error_status( + Code::InvalidArgument, + "too many inputs", + "bounds_exceeded", + 400, + ); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + assert_eq!(err.body.error, "bounds_exceeded"); + assert_eq!(err.body.message, "too many inputs"); + } + + #[test] + fn maps_challenge_expired_410() { + let st = encode_kernel_error_status( + Code::Unauthenticated, + "challenge gone", + "challenge_expired", + 410, + ); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::GONE); + assert_eq!(err.body.error, "challenge_expired"); + assert_eq!(err.body.message, "challenge gone"); + } + + #[test] + fn challenge_expired_with_wrong_http_status_is_fail_closed_500() { + let st = encode_kernel_error_status( + Code::Unauthenticated, + "challenge gone", + "challenge_expired", + 401, + ); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, PUBLIC_INTERNAL_MESSAGE); + assert!( + err.cause().unwrap_or("").contains("410"), + "cause must name required 410, got {:?}", + err.cause() + ); + } + + #[test] + fn wrong_grpc_code_for_reason_is_fail_closed_500() { + // job_not_found requires NotFound, not Internal. + let st = encode_kernel_error_status(Code::Internal, "x", "job_not_found", 404); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert!( + err.cause().unwrap_or("").contains("gRPC") + || err.cause().unwrap_or("").contains("NotFound"), + "cause must name gRPC mismatch, got {:?}", + err.cause() + ); + } + + #[test] + fn job_payload_only_reason_as_rpc_is_fail_closed() { + let st = encode_kernel_error_status(Code::Internal, "x", "proving_failed", 500); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_ne!(err.body.error, "proving_failed"); + assert!( + err.cause().unwrap_or("").contains("proving_failed") + || err.cause().unwrap_or("").contains("job-payload"), + "cause must name the forbidden reason, got {:?}", + err.cause() + ); + } + + #[test] + fn feature_disabled_as_rpc_is_fail_closed() { + let st = encode_kernel_error_status(Code::NotFound, "x", "feature_disabled", 404); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_ne!(err.body.error, "feature_disabled"); + } + + #[test] + fn procedure_rejects_foreign_reason() { + // job_not_found is valid globally but not for GetInfo. + let st = encode_kernel_error_status(Code::NotFound, "Job not found", "job_not_found", 404); + let err = kernel_status_to_api_error_for(&st, Some(KernelProcedure::GetInfo)); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert!( + err.cause().unwrap_or("").contains("not allowed") + || err.cause().unwrap_or("").contains("GetInfo"), + "cause must name procedure filter, got {:?}", + err.cause() + ); + } + + #[test] + fn procedure_accepts_allowed_reason() { + let st = encode_kernel_error_status(Code::NotFound, "Job not found", "job_not_found", 404); + let err = kernel_status_to_api_error_for(&st, Some(KernelProcedure::GetJob)); + assert_eq!(err.status, StatusCode::NOT_FOUND); + assert_eq!(err.body.error, "job_not_found"); + } + + #[test] + fn missing_http_status_is_fail_closed_500() { + let mut metadata = HashMap::new(); + metadata.insert("other".to_string(), "x".to_string()); + let details = ErrorDetails::with_error_info("job_not_found", ERROR_INFO_DOMAIN, metadata); + let st = Status::with_error_details(Code::NotFound, "x", details); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, PUBLIC_INTERNAL_MESSAGE); + assert!( + err.cause().unwrap_or("").contains("http_status"), + "operator cause must name the missing field, got {:?}", + err.cause() + ); + } + + #[test] + fn invalid_http_status_is_fail_closed_500() { + let st = encode_kernel_error_status(Code::Internal, "x", "internal_error", 200); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, PUBLIC_INTERNAL_MESSAGE); + let cause = err.cause().unwrap_or(""); + assert!( + cause.contains("requires http_status") + || cause.contains("http_status") + || cause.contains("500"), + "operator cause must name the status problem, got {cause}" + ); + } + + #[test] + fn wrong_domain_is_fail_closed_500() { + let mut metadata = HashMap::new(); + metadata.insert("http_status".to_string(), "404".to_string()); + let details = ErrorDetails::with_error_info("job_not_found", "not.kernel", metadata); + let st = Status::with_error_details(Code::NotFound, "x", details); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert!( + err.cause().unwrap_or("").contains("domain"), + "operator cause must name domain failure, got {:?}", + err.cause() + ); + } + + #[test] + fn empty_details_is_fail_closed_500() { + let st = Status::new(Code::Internal, "bare status"); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert!( + err.cause().unwrap_or("").contains("ErrorInfo") + || err.cause().unwrap_or("").contains("details"), + "operator cause must mention ErrorInfo/details, got {:?}", + err.cause() + ); + } + + /// Bare `Any(ErrorInfo)` is **not** the production wire shape and must + /// fail closed (node uses `google.rpc.Status` envelope via tonic-types). + #[test] + fn bare_any_error_info_is_rejected() { + #[derive(Clone, PartialEq, prost::Message)] + struct LocalErrorInfo { + #[prost(string, tag = "1")] + reason: String, + #[prost(string, tag = "2")] + domain: String, + #[prost(map = "string, string", tag = "3")] + metadata: HashMap, + } + let mut metadata = HashMap::new(); + metadata.insert("http_status".to_string(), "404".to_string()); + let info = LocalErrorInfo { + reason: "job_not_found".to_string(), + domain: ERROR_INFO_DOMAIN.to_string(), + metadata, + }; + let any = prost_types::Any { + type_url: "type.googleapis.com/google.rpc.ErrorInfo".to_string(), + value: info.encode_to_vec(), + }; + let st = Status::with_details(Code::NotFound, "x", any.encode_to_vec().into()); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, PUBLIC_INTERNAL_MESSAGE); + } + + #[test] + fn non_canonical_http_status_string_is_fail_closed() { + let mut metadata = HashMap::new(); + metadata.insert("http_status".to_string(), "0404".to_string()); + let details = ErrorDetails::with_error_info("job_not_found", ERROR_INFO_DOMAIN, metadata); + let st = Status::with_error_details(Code::NotFound, "x", details); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert!( + err.cause().unwrap_or("").contains("canonical"), + "operator cause must name canonical form, got {:?}", + err.cause() + ); + } + + #[test] + fn unknown_error_info_reason_is_fail_closed_500_not_forwarded() { + let st = encode_kernel_error_status( + Code::Internal, + "kernel invented a code", + "totally_made_up_reason", + 500, + ); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_ne!(err.body.error, "totally_made_up_reason"); + let cause = err.cause().unwrap_or(""); + assert!( + cause.contains("totally_made_up_reason") + || cause.contains("machine_code") + || cause.contains("closed"), + "operator cause must name the foreign reason, got {cause}" + ); + } + + #[test] + fn unauthorized_with_wrong_http_status_is_fail_closed_500() { + let st = + encode_kernel_error_status(Code::PermissionDenied, "not allowed", "unauthorized", 403); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, PUBLIC_INTERNAL_MESSAGE); + assert!( + err.cause().unwrap_or("").contains("401") + || err.cause().unwrap_or("").contains("gRPC") + || err.cause().unwrap_or("").contains("http_status"), + "cause must name the pairing failure, got {:?}", + err.cause() + ); + } + + #[test] + fn session_expired_with_wrong_http_status_is_fail_closed_500() { + let st = encode_kernel_error_status( + Code::Unauthenticated, + "session gone", + "session_expired", + 401, + ); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert!( + err.cause().unwrap_or("").contains("410"), + "cause must name the required 410 pairing, got {:?}", + err.cause() + ); + } + + #[test] + fn unauthorized_401_and_session_expired_410_are_accepted() { + let u = encode_kernel_error_status(Code::Unauthenticated, "nope", "unauthorized", 401); + let err = kernel_status_to_api_error(&u); + assert_eq!(err.status, StatusCode::UNAUTHORIZED); + assert_eq!(err.body.error, "unauthorized"); + + let s = encode_kernel_error_status(Code::Unauthenticated, "gone", "session_expired", 410); + let err = kernel_status_to_api_error(&s); + assert_eq!(err.status, StatusCode::GONE); + assert_eq!(err.body.error, "session_expired"); + } + + /// Kernel `internal_error` must never leak the status message onto the wire. + #[test] + fn internal_error_public_message_is_neutral_secret_not_on_wire() { + const SECRET: &str = "/var/lib/zkcoins/SECRET_DB_PATH_xyz_do_not_leak"; + let st = encode_kernel_error_status(Code::Internal, SECRET, "internal_error", 500); + let err = kernel_status_to_api_error(&st); + assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(err.body.error, "internal_error"); + assert_eq!(err.body.message, PUBLIC_INTERNAL_MESSAGE); + assert!( + !err.body.message.contains("SECRET"), + "public message must not carry secret" + ); + assert!( + err.cause().unwrap_or("").contains("SECRET_DB_PATH"), + "operator cause must retain the diagnostic, got {:?}", + err.cause() + ); + } + + #[test] + fn closed_rpc_triple_table_covers_normative_codes() { + for reason in [ + "job_not_found", + "bounds_exceeded", + "malformed_request", + "session_expired", + "challenge_expired", + "dependency_not_final", + "internal_error", + "circuit_digest_mismatch", + "rate_limited", + "scope_exceeded", + "unauthorized", + ] { + assert!( + lookup_triple(reason).is_some(), + "RPC triple table must include {reason:?}" + ); + } + // Job-payload / API-only must stay out of the RPC table. + assert!(lookup_triple("proving_failed").is_none()); + assert!(lookup_triple("publish_rejected").is_none()); + assert!(lookup_triple("feature_disabled").is_none()); + assert!(lookup_triple("totally_made_up").is_none()); + } + + #[test] + fn encode_uses_google_rpc_status_envelope_not_bare_any() { + let st = encode_kernel_error_status(Code::NotFound, "Job not found", "job_not_found", 404); + // Production decoder path must succeed. + let err = kernel_status_to_api_error(&st); + assert_eq!(err.body.error, "job_not_found"); + // Details must decode as a multi-detail google.rpc.Status envelope. + let vec = st + .check_error_details_vec() + .expect("production encoder must pack google.rpc.Status details"); + assert_eq!(vec.len(), 1); + match &vec[0] { + ErrorDetail::ErrorInfo(info) => { + assert_eq!(info.reason, "job_not_found"); + assert_eq!(info.domain, ERROR_INFO_DOMAIN); + } + other => panic!("expected ErrorInfo, got {other:?}"), + } + } +} diff --git a/src/kernel/mod.rs b/src/kernel/mod.rs new file mode 100644 index 0000000..d4776cf --- /dev/null +++ b/src/kernel/mod.rs @@ -0,0 +1,18 @@ +//! Kernel gRPC boundary: generated `kernel.v1` types, client, and ErrorInfo map. +//! +//! The api holds no protocol state. Handlers translate REST ↔ these types and +//! forward every call to the kernel process. + +mod client; +mod error_info; +mod pb; + +pub use client::{connect_lazy, KernelClient, KernelHandle, KernelRpc}; +pub use error_info::{ + kernel_status_to_api_error, kernel_status_to_api_error_for, transport_error_to_api_error, + KernelProcedure, ERROR_INFO_DOMAIN, +}; +pub use pb::kernel_v1; + +#[cfg(test)] +pub use error_info::{encode_kernel_error_status, ErrorInfo}; diff --git a/src/kernel/pb.rs b/src/kernel/pb.rs new file mode 100644 index 0000000..035ac07 --- /dev/null +++ b/src/kernel/pb.rs @@ -0,0 +1,7 @@ +//! Re-export of generated `kernel.v1` types from the `kernel-proto` crate. +//! +//! Codegen lives in `kernel-proto` (own build.rs / OUT_DIR) so that +//! `cargo clippy -p api` never sees tonic-build output. + +/// Generated `kernel.v1` package (types + client stubs). +pub use kernel_proto as kernel_v1; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8685f70 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,28 @@ +//! zkCoins public REST API layer. +//! +//! Outward surface of specification §7.5. Consumes the kernel RPC (§7.8) via +//! `tonic`. Holds no protocol state, no value-bearing store, and no secrets. + +pub mod attest; +pub mod blossom; +pub mod bootstrap; +pub mod chain; +pub mod config; +pub mod error; +pub mod extract; +pub mod grants; +pub mod hexutil; +pub mod info; +pub mod jobs; +pub mod kernel; +pub mod ownership; +pub mod proto_identity; +pub mod publish; +pub mod pull; +pub mod routes; +pub mod state; + +pub use config::{BlossomConfig, Config, ConfigError, Feature}; +pub use kernel::{connect_lazy, KernelClient, KernelHandle}; +pub use routes::{build_router, StartupError, CLOSED_ENDPOINT_KEYS}; +pub use state::AppState; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..04478e1 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,77 @@ +//! zkCoins API process entrypoint. +//! +//! Configuration is fail-closed: missing or invalid environment variables +//! abort startup with a named error. No default bind host, no default kernel +//! address, no silent feature fallthrough. + +use api::{build_router, connect_lazy, Config}; +use std::net::SocketAddr; +use std::process::ExitCode; +use std::sync::Arc; +use tracing::info; + +#[tokio::main] +async fn main() -> ExitCode { + init_tracing(); + + let config = match Config::from_env() { + Ok(c) => c, + Err(e) => { + eprintln!("api: configuration error: {e}"); + return ExitCode::from(1); + } + }; + + let kernel: api::KernelHandle = match connect_lazy(&config.kernel_addr) { + Ok(c) => Arc::new(c), + Err(e) => { + eprintln!("api: kernel client error: {e}"); + return ExitCode::from(1); + } + }; + + let bind_addr: SocketAddr = config.bind_addr; + let kernel_addr = config.kernel_addr.clone(); + let feature_count = config.features.len(); + + let app = match build_router(config, kernel) { + Ok(r) => r, + Err(e) => { + eprintln!("api: startup error: {e}"); + return ExitCode::from(1); + } + }; + + let listener = match tokio::net::TcpListener::bind(bind_addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("api: failed to bind {bind_addr}: {e}"); + return ExitCode::from(1); + } + }; + + info!( + %bind_addr, + %kernel_addr, + feature_count, + "zkcoins-api listening (health + info/chain + jobs + attest/grants + pull + bootstrap + publish + optional blossom)" + ); + + if let Err(e) = axum::serve(listener, app).await { + eprintln!("api: server error: {e}"); + return ExitCode::from(1); + } + + ExitCode::SUCCESS +} + +fn init_tracing() { + // Honour RUST_LOG when set; otherwise info. `try_init` so a second + // install in tests does not panic. + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_target(false) + .try_init(); +} diff --git a/src/ownership.rs b/src/ownership.rs new file mode 100644 index 0000000..4fc3147 --- /dev/null +++ b/src/ownership.rs @@ -0,0 +1,2441 @@ +//! Action-bound OwnershipProof verification at the API edge (§5.1 / §7.5). +//! +//! The kernel gRPC surface carries **no** OwnershipProof fields: this module +//! is the sole place that verifies BIP-340 ownership before any kernel call +//! that would consume a challenge nonce. +//! +//! ## Domain binding +//! +//! Challenge domains are endpoint-selected constants ([`ChallengeDomain`]). +//! Callers pass the domain of the route they are serving — never a string +//! from the request body. A proof signed under AttestBalance cannot authorise +//! IssueGrant, and vice versa. +//! +//! ## Nonce non-consumption +//! +//! Every check in [`verify_ownership_proof`] is pure. The kernel is only +//! dialed by the handler **after** this function returns `Ok`. A failed +//! signature therefore cannot burn the single-use nonce in the kernel store. + +use crate::error::ApiError; +use crate::hexutil::{decode_hex_exact, encode_hex}; +use bech32::primitives::decode::CheckedHrpstring; +use bech32::Bech32m; +use bitcoin::secp256k1::{ + schnorr::Signature as SchnorrSignature, Message, Secp256k1, XOnlyPublicKey, +}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::sync::RwLock; + +// --------------------------------------------------------------------------- +// Domain tags — taken from node `ChallengeAction::domain()` (sole definition +// there). Word-for-word match is the cryptographic action binding. +// --------------------------------------------------------------------------- + +/// `ChallengeAction::Pull.domain()` in +/// `node/src/kernel/bootstrap/challenges.rs`. +pub const PULL_CHALLENGE_DOMAIN: &str = "zkCoins/v1/PullChallenge"; + +/// `ChallengeAction::AttestBalance.domain()` in +/// `node/src/kernel/bootstrap/challenges.rs`. +pub const ATTEST_BALANCE_CHALLENGE_DOMAIN: &str = "zkCoins/v1/AttestBalanceChallenge"; + +/// `ChallengeAction::IssueViewGrant.domain()` in +/// `node/src/kernel/bootstrap/challenges.rs`. +pub const ISSUE_GRANT_CHALLENGE_DOMAIN: &str = "zkCoins/v1/IssueGrantChallenge"; + +/// `ChallengeAction::Entrust.domain()` in +/// `node/src/kernel/bootstrap/challenges.rs`. +pub const ENTRUST_CHALLENGE_DOMAIN: &str = "zkCoins/v1/EntrustChallenge"; + +/// `ChallengeAction::Revoke.domain()` in +/// `node/src/kernel/bootstrap/challenges.rs`. +pub const REVOKE_CHALLENGE_DOMAIN: &str = "zkCoins/v1/RevokeChallenge"; + +/// §7.5 `request_hash` tag for `POST /v1/attest/balance`. +pub const ATTEST_BALANCE_REQUEST_TAG: &str = "zkCoins/v1/AttestBalance"; + +/// §7.5 `request_hash` tag for `POST /v1/grants`. +pub const ISSUE_GRANT_REQUEST_TAG: &str = "zkCoins/v1/IssueGrant"; + +/// §5.1 clearnet `chan_bind` host domain. +pub const PULL_HOST_DOMAIN: &str = "zkCoins/v1/PullHost"; + +/// Bech32m HRP for a zkCoins address (§1.7.7). +pub const ADDRESS_HRP: &str = "zk"; + +/// Bech32m HRP for a serialised view grant (§5.2 / §1.7.7). +pub const GRANT_HRP: &str = "zkgrant"; + +/// §5.2 `grant_message` domain tag (Foundations `Grant` context). +pub const GRANT_MESSAGE_TAG: &str = "zkCoins/v1/Grant"; + +/// §5.2 grant version byte (currently always `0x01`). +pub const GRANT_VERSION: u8 = 0x01; + +/// Unbounded `not_after` sentinel: `2⁶³−1` (§5.1). +pub const SCOPE_NOT_AFTER_UNBOUNDED: u64 = 9_223_372_036_854_775_807; + +// Lock the §5.1 bit-pattern: unbounded not_after is exactly i64::MAX as u64. +const _: () = assert!(SCOPE_NOT_AFTER_UNBOUNDED == i64::MAX as u64); + +// Goldilocks field order — nk_commit limbs on the wire must be strictly `< p` +// (same fail-loud rule as node `digest_from_bytes`). +const GOLDILOCKS_ORDER: u64 = 0xffff_ffff_0000_0001; + +/// Closed set of challenge domains this stage verifies. +/// +/// The domain string is a method on the enum — callers cannot pass an +/// arbitrary domain from the request body. Entrust and Revoke are distinct +/// from Pull so a proof cannot be retargeted across bootstrap actions (§7.7). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChallengeDomain { + /// `POST /v1/pull` — no `request_hash` in `chal` (§5.1 L1916). + Pull, + AttestBalance, + IssueGrant, + /// `POST /v1/bootstrap/entrust` — no `request_hash` (§7.7). + Entrust, + /// `POST /v1/bootstrap/revoke` — no `request_hash` (§7.7). + Revoke, +} + +impl ChallengeDomain { + /// Normative domain tag for this action (§5.1 table / node source). + pub const fn as_str(self) -> &'static str { + match self { + ChallengeDomain::Pull => PULL_CHALLENGE_DOMAIN, + ChallengeDomain::AttestBalance => ATTEST_BALANCE_CHALLENGE_DOMAIN, + ChallengeDomain::IssueGrant => ISSUE_GRANT_CHALLENGE_DOMAIN, + ChallengeDomain::Entrust => ENTRUST_CHALLENGE_DOMAIN, + ChallengeDomain::Revoke => REVOKE_CHALLENGE_DOMAIN, + } + } + + /// Whether `chal` omits `request_hash` (pull / bootstrap). + pub const fn is_simple(self) -> bool { + matches!( + self, + ChallengeDomain::Pull | ChallengeDomain::Entrust | ChallengeDomain::Revoke + ) + } +} + +/// §7.5 / §5.1(a) `OwnershipProofJson` on the wire. +#[derive(Debug, Clone, Deserialize)] +pub struct OwnershipProofJson { + #[serde(rename = "type")] + pub proof_type: String, + pub subject: String, + pub public_key: String, + pub nk_commit: String, + pub signature: String, +} + +/// Tagged proof union for **owner-only** endpoints (Attest, IssueGrant, +/// Entrust, Revoke). Deserialises a real GrantProof shape as the `grant` arm +/// so clients receive `401 unauthorized` (capability gate) rather than +/// `400 malformed_request` from missing Ownership fields. +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +pub enum OwnerOnlyProofJson { + #[serde(rename = "ownership")] + Ownership { + subject: String, + public_key: String, + nk_commit: String, + signature: String, + }, + #[serde(rename = "grant")] + Grant { + grant: String, + grantee_pk: String, + signature: String, + }, +} + +impl OwnerOnlyProofJson { + /// Reject GrantProof with `401 unauthorized`; return OwnershipProof fields. + pub fn require_ownership(self) -> Result { + match self { + Self::Ownership { + subject, + public_key, + nk_commit, + signature, + } => Ok(OwnershipProofJson { + proof_type: "ownership".into(), + subject, + public_key, + nk_commit, + signature, + }), + Self::Grant { .. } => Err(ApiError::unauthorized( + "GrantProof does not authorise this owner-only action \ + (AttestBalance / IssueViewGrant / Entrust / Revoke require OwnershipProof; \ + no-escalation)", + )), + } + } +} + +/// Validate normalised scope invariants before any Challenge/Redeem RPC: +/// - explicit `asset_ids` strictly ascending and unique; +/// - time interval non-empty (`not_before <= not_after`). +pub fn validate_resolved_scope(scope: &ResolvedScope) -> Result<(), ApiError> { + if !scope.all_assets { + if scope.asset_ids.is_empty() { + return Err(ApiError::malformed( + "scope.asset_ids list must be non-empty when not \"*\"", + )); + } + for window in scope.asset_ids.windows(2) { + if window[0] >= window[1] { + return Err(ApiError::malformed( + "scope.asset_ids must be strictly ascending and unique", + )); + } + } + } else if !scope.asset_ids.is_empty() { + return Err(ApiError::internal( + "ResolvedScope invariant: all_assets with non-empty asset_ids", + )); + } + if scope.not_before > scope.not_after { + return Err(ApiError::malformed( + "scope time interval is empty (not_before > not_after)", + )); + } + Ok(()) +} + +/// Challenge fields echoed by the client so the API can recompute `chal` +/// without holding challenge state. +/// +/// Spec §7.5 abbreviated bodies list only `nonce` (the monlithic node looked +/// up `expiry` from its local store). On a **stateless** API edge the client +/// MUST resubmit the issued `expiry` so BIP-340 verification can run +/// **before** any kernel call that would consume the nonce. +#[derive(Debug, Clone, Deserialize)] +pub struct ChallengeEcho { + pub nonce: String, + /// §7.1 decimal-string u64 (same wire form as the challenge response). + pub expiry: String, +} + +/// Outcome of a successful OwnershipProof verification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedOwnership { + /// Bech32m subject string as accepted on the request. + pub subject_bech32: String, + /// 32-byte address digest (`H(Pk₀ ‖ nk_commit)`). + pub subject_raw: [u8; 32], + pub nonce: [u8; 32], + /// Challenge expiry from the client echo (bound into the signed `chal`). + pub challenge_expiry: u64, + /// The authoritative `chan_bind` that accepted the signature. + pub chan_bind: [u8; 32], +} + +// --------------------------------------------------------------------------- +// Hash helpers +// --------------------------------------------------------------------------- + +fn sha256(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +/// `chan_bind = H("zkCoins/v1/PullHost" ‖ host)` for clearnet (§5.1). +/// +/// `host` must already be the server's canonical authority (from config), +/// never a client-supplied or `Host`-header value. +pub fn chan_bind_for_host(host: &str) -> [u8; 32] { + let mut pre = Vec::with_capacity(PULL_HOST_DOMAIN.len() + host.len()); + pre.extend_from_slice(PULL_HOST_DOMAIN.as_bytes()); + pre.extend_from_slice(host.as_bytes()); + sha256(&pre) +} + +/// `chal = H(domain ‖ nonce ‖ chan_bind ‖ subject ‖ expiry ‖ request_hash)`. +/// +/// Spec §5.1 L1963 (AttestBalance / IssueGrant form with `request_hash`). +/// `domain` is UTF-8 of the action tag; `nonce`/`chan_bind`/`subject`/ +/// `request_hash` are 32 raw bytes; `expiry` is u64 big-endian. +pub fn ownership_challenge_message( + domain: &str, + nonce: &[u8; 32], + chan_bind: &[u8; 32], + subject: &[u8; 32], + expiry: u64, + request_hash: &[u8; 32], +) -> [u8; 32] { + let mut pre = Vec::with_capacity(domain.len() + 32 + 32 + 32 + 8 + 32); + pre.extend_from_slice(domain.as_bytes()); + pre.extend_from_slice(nonce); + pre.extend_from_slice(chan_bind); + pre.extend_from_slice(subject); + pre.extend_from_slice(&expiry.to_be_bytes()); + pre.extend_from_slice(request_hash); + sha256(&pre) +} + +/// `chal = H(domain ‖ nonce ‖ chan_bind ‖ subject ‖ expiry)` for pull / bootstrap +/// (§5.1 L1916 — no `request_hash`). +/// +/// `domain` is UTF-8 of the action tag; `nonce`/`chan_bind`/`subject` are 32 +/// raw bytes; `expiry` is u64 big-endian. Body `expiry` is bound into this +/// digest (Redeem-body `expiry` normative): a forged value yields a different +/// `chal` and fails signature verification. +pub fn pull_challenge_message( + domain: &str, + nonce: &[u8; 32], + chan_bind: &[u8; 32], + subject: &[u8; 32], + expiry: u64, +) -> [u8; 32] { + let mut pre = Vec::with_capacity(domain.len() + 32 + 32 + 32 + 8); + pre.extend_from_slice(domain.as_bytes()); + pre.extend_from_slice(nonce); + pre.extend_from_slice(chan_bind); + pre.extend_from_slice(subject); + pre.extend_from_slice(&expiry.to_be_bytes()); + sha256(&pre) +} + +/// Ceiling encoding for attest `request_hash` (§7.5 L2894): +/// - both omitted → `0x00` +/// - both present → `0x01 ‖ nav_ceiling (32B) ‖ u64-be(size_ceiling)` +/// - any other combination → `400 malformed_request` +pub fn ceiling_encoding( + nav_ceiling: Option<&[u8; 32]>, + size_ceiling: Option, +) -> Result, ApiError> { + match (nav_ceiling, size_ceiling) { + (None, None) => Ok(vec![0x00]), + (Some(nav), Some(size)) => { + let mut out = Vec::with_capacity(1 + 32 + 8); + out.push(0x01); + out.extend_from_slice(nav); + out.extend_from_slice(&size.to_be_bytes()); + Ok(out) + } + _ => Err(ApiError::malformed( + "nav_ceiling and size_ceiling must both be present or both omitted (§7.5)", + )), + } +} + +/// `request_hash = H("zkCoins/v1/AttestBalance" ‖ subject ‖ asset_id ‖ ceiling_encoding)`. +pub fn attest_request_hash( + subject: &[u8; 32], + asset_id: &[u8; 32], + ceiling_enc: &[u8], +) -> [u8; 32] { + let mut pre = + Vec::with_capacity(ATTEST_BALANCE_REQUEST_TAG.len() + 32 + 32 + ceiling_enc.len()); + pre.extend_from_slice(ATTEST_BALANCE_REQUEST_TAG.as_bytes()); + pre.extend_from_slice(subject); + pre.extend_from_slice(asset_id); + pre.extend_from_slice(ceiling_enc); + sha256(&pre) +} + +/// Encode grant `asset_ids` as in `grant_message` (§5.2): `0x00` for `*`, +/// or `0x01 ‖ u32-be count ‖ ascending 32-byte ids`. +pub fn encode_grant_asset_ids( + all_assets: bool, + asset_ids: &[[u8; 32]], +) -> Result, ApiError> { + if all_assets { + if !asset_ids.is_empty() { + return Err(ApiError::malformed( + "scope.asset_ids must be empty when asset_ids is \"*\"", + )); + } + return Ok(vec![0x00]); + } + if asset_ids.is_empty() { + return Err(ApiError::malformed( + "scope.asset_ids list must be non-empty when not \"*\"", + )); + } + for w in asset_ids.windows(2) { + if w[0] >= w[1] { + return Err(ApiError::malformed( + "scope.asset_ids must be strictly ascending", + )); + } + } + let count = u32::try_from(asset_ids.len()) + .map_err(|_| ApiError::malformed("scope.asset_ids count exceeds u32"))?; + let mut out = Vec::with_capacity(1 + 4 + asset_ids.len() * 32); + out.push(0x01); + out.extend_from_slice(&count.to_be_bytes()); + for id in asset_ids { + out.extend_from_slice(id); + } + Ok(out) +} + +/// `request_hash = H("zkCoins/v1/IssueGrant" ‖ subject ‖ grantee_pk ‖ +/// asset_ids ‖ not_before ‖ not_after ‖ expiry)` (§7.5 L2896). +pub fn issue_grant_request_hash( + subject: &[u8; 32], + grantee_pk: &[u8; 32], + asset_enc: &[u8], + not_before: u64, + not_after: u64, + grant_expiry: u64, +) -> [u8; 32] { + let mut pre = + Vec::with_capacity(ISSUE_GRANT_REQUEST_TAG.len() + 32 + 32 + asset_enc.len() + 8 + 8 + 8); + pre.extend_from_slice(ISSUE_GRANT_REQUEST_TAG.as_bytes()); + pre.extend_from_slice(subject); + pre.extend_from_slice(grantee_pk); + pre.extend_from_slice(asset_enc); + pre.extend_from_slice(¬_before.to_be_bytes()); + pre.extend_from_slice(¬_after.to_be_bytes()); + pre.extend_from_slice(&grant_expiry.to_be_bytes()); + sha256(&pre) +} + +// --------------------------------------------------------------------------- +// Wire parsers +// --------------------------------------------------------------------------- + +/// Parse a §7.1 canonical decimal-string u64 (`0|[1-9][0-9]*`). +pub fn parse_u64_decimal(s: &str) -> Result { + if s.is_empty() { + return Err(ApiError::malformed("empty decimal string")); + } + if s == "0" { + return Ok(0); + } + if s.as_bytes()[0] == b'0' { + return Err(ApiError::malformed( + "leading zeros are not allowed in canonical u64 decimal strings", + )); + } + if !s.bytes().all(|b| b.is_ascii_digit()) { + return Err(ApiError::malformed( + "decimal string must contain only ASCII digits", + )); + } + s.parse::() + .map_err(|_| ApiError::malformed(format!("decimal string out of u64 range: {s}"))) +} + +/// Decode a Bech32m `zk` address to its 32-byte payload. +pub fn decode_zk_address(s: &str) -> Result<[u8; 32], ApiError> { + let checked = CheckedHrpstring::new::(s) + .map_err(|e| ApiError::malformed(format!("subject: invalid Bech32m address: {e}")))?; + if checked.hrp().as_str() != ADDRESS_HRP { + return Err(ApiError::malformed(format!( + "subject: expected HRP {ADDRESS_HRP:?}, got {:?}", + checked.hrp().as_str() + ))); + } + let data: Vec = checked.byte_iter().collect(); + if data.len() != 32 { + return Err(ApiError::malformed(format!( + "subject: address payload must be 32 bytes, got {}", + data.len() + ))); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&data); + Ok(out) +} + +/// Encode 32 raw address bytes as Bech32m `zk` (tests / helpers). +#[cfg(test)] +pub fn encode_zk_address(raw: &[u8; 32]) -> String { + let hrp = bech32::Hrp::parse(ADDRESS_HRP).expect("constant HRP"); + bech32::encode::(hrp, raw).expect("32-byte payload encodes") +} + +fn parse_hex32_field(s: &str, field: &str) -> Result<[u8; 32], ApiError> { + let v = decode_hex_exact(s, 32).map_err(|e| ApiError::malformed(format!("{field}: {e}")))?; + let mut out = [0u8; 32]; + out.copy_from_slice(&v); + Ok(out) +} + +fn parse_hex64_field(s: &str, field: &str) -> Result<[u8; 64], ApiError> { + let v = decode_hex_exact(s, 64).map_err(|e| ApiError::malformed(format!("{field}: {e}")))?; + let mut out = [0u8; 64]; + out.copy_from_slice(&v); + Ok(out) +} + +/// Reject non-canonical Goldilocks limbs in an `nk_commit` wire value. +fn validate_nk_commit_limbs(bytes: &[u8; 32]) -> Result<(), ApiError> { + for i in 0..4 { + let mut buf = [0u8; 8]; + buf.copy_from_slice(&bytes[i * 8..(i + 1) * 8]); + let limb = u64::from_be_bytes(buf); + if limb >= GOLDILOCKS_ORDER { + return Err(ApiError::malformed(format!( + "ownership_proof.nk_commit: non-canonical Goldilocks limb {i}" + ))); + } + } + Ok(()) +} + +/// `address = SHA-256(Pk₀ ‖ nk_commit_bytes)` (§1.4) where `nk_commit_bytes` +/// is the canonical 32-byte digest encoding on the wire. +fn address_from_pk0_nk_commit(pk0: &[u8; 32], nk_commit: &[u8; 32]) -> [u8; 32] { + let mut pre = [0u8; 64]; + pre[..32].copy_from_slice(pk0); + pre[32..].copy_from_slice(nk_commit); + sha256(&pre) +} + +// --------------------------------------------------------------------------- +// BIP-340 +// --------------------------------------------------------------------------- + +/// Verify BIP-340 Schnorr over a 32-byte message digest under an x-only key. +/// +/// Uses `bitcoin::secp256k1` — the same stack as zk-coins/node. +/// `fail_message` is returned on cryptographic mismatch (wrong key, bad sig, +/// wrong preimage) so callers can name OwnershipProof vs GrantProof context. +pub fn verify_bip340( + pk: &[u8; 32], + signature: &[u8; 64], + message_digest: &[u8; 32], +) -> Result<(), ApiError> { + verify_bip340_with_message( + pk, + signature, + message_digest, + "public key is not a valid x-only pubkey", + "signature is not a valid BIP-340 signature", + "BIP-340 signature invalid (key, preimage, or chan_bind/domain mismatch)", + ) +} + +/// BIP-340 verify with caller-chosen unauthorized messages (grant vs ownership). +pub fn verify_bip340_with_message( + pk: &[u8; 32], + signature: &[u8; 64], + message_digest: &[u8; 32], + bad_pk_message: &str, + bad_sig_encoding_message: &str, + verify_fail_message: &str, +) -> Result<(), ApiError> { + let xonly = XOnlyPublicKey::from_slice(pk) + .map_err(|_| ApiError::unauthorized(bad_pk_message.to_string()))?; + let sig = SchnorrSignature::from_slice(signature) + .map_err(|_| ApiError::unauthorized(bad_sig_encoding_message.to_string()))?; + let msg = Message::from_digest_slice(message_digest) + .map_err(|_| ApiError::internal("BIP-340 message digest must be 32 bytes"))?; + let secp = Secp256k1::verification_only(); + secp.verify_schnorr(&sig, &msg, &xonly) + .map_err(|_| ApiError::unauthorized(verify_fail_message.to_string())) +} + +// --------------------------------------------------------------------------- +// Capability gate (order-independent GrantProof rejection) +// --------------------------------------------------------------------------- + +/// Closed capability kind for owner-only actions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OwnerOnlyCapability { + Ownership, + Grant, +} + +fn capability_from_wire(proof_type: &str) -> Result { + match proof_type { + "ownership" => Ok(OwnerOnlyCapability::Ownership), + "grant" => Ok(OwnerOnlyCapability::Grant), + other => Err(ApiError::unauthorized(format!( + "unknown capability type {other:?}; only OwnershipProof authorises this action" + ))), + } +} + +fn require_ownership(kind: OwnerOnlyCapability) -> Result<(), ApiError> { + match kind { + OwnerOnlyCapability::Ownership => Ok(()), + OwnerOnlyCapability::Grant => Err(ApiError::unauthorized( + "GrantProof does not authorise this owner-only action \ + (AttestBalance / IssueViewGrant / Entrust / Revoke require OwnershipProof; \ + no-escalation)", + )), + } +} + +// --------------------------------------------------------------------------- +// Main gate +// --------------------------------------------------------------------------- + +/// Verify an action-bound OwnershipProof **without** calling the kernel. +/// +/// # Arguments +/// +/// * `domain` — from the **endpoint**, via [`ChallengeDomain`] (not the body) +/// * `request_subject` — Bech32m subject on the outer request +/// * `challenge` — client echo of issued `{ nonce, expiry }` +/// * `proof` — `OwnershipProofJson` +/// * `request_hash` — server-computed digest of the request body fields +/// * `public_hosts` — authoritative hosts from server config +/// +/// On success returns the `chan_bind` that accepted the signature and the +/// decoded subject/nonce for the subsequent kernel RPC. +pub fn verify_ownership_proof( + domain: ChallengeDomain, + request_subject: &str, + challenge: &ChallengeEcho, + proof: &OwnershipProofJson, + request_hash: &[u8; 32], + public_hosts: &[String], +) -> Result { + // 1. Closed capability match — GrantProof rejected by typed arm. + let capability = capability_from_wire(&proof.proof_type)?; + require_ownership(capability)?; + + // 2. Subject identity (Bech32m + proof subject equality). + let subject_raw = decode_zk_address(request_subject)?; + let proof_subject_raw = decode_zk_address(&proof.subject)?; + if proof_subject_raw != subject_raw { + return Err(ApiError::unauthorized( + "ownership_proof.subject does not match request subject", + )); + } + + // 3. Parse fixed-width proof fields. + let pk0 = parse_hex32_field(&proof.public_key, "ownership_proof.public_key")?; + let nk_commit = parse_hex32_field(&proof.nk_commit, "ownership_proof.nk_commit")?; + validate_nk_commit_limbs(&nk_commit)?; + let signature = parse_hex64_field(&proof.signature, "ownership_proof.signature")?; + let nonce = parse_hex32_field(&challenge.nonce, "challenge.nonce")?; + let challenge_expiry = parse_u64_decimal(&challenge.expiry) + .map_err(|e| ApiError::malformed(format!("challenge.expiry: {}", e.body.message)))?; + + // 4. Address binding: H(Pk₀ ‖ nk_commit) == subject (§5.1(a)). + let expected = address_from_pk0_nk_commit(&pk0, &nk_commit); + if expected != subject_raw { + return Err(ApiError::unauthorized( + "H(Pk0 ‖ nk_commit) does not equal subject address", + )); + } + + // 5. Authoritative chan_bind set (config only — never Host header). + if public_hosts.is_empty() { + return Err(ApiError::internal( + "no authoritative public hosts configured for chan_bind (ZKCOINS_PUBLIC_HOST)", + )); + } + let allowed: Vec<[u8; 32]> = public_hosts.iter().map(|h| chan_bind_for_host(h)).collect(); + + // 6. BIP-340 over chal under the **endpoint** domain. Try each host's + // chan_bind; accept the first that verifies. Domain is NOT taken from + // the body — a proof signed under the other action domain fails here. + let domain_str = domain.as_str(); + let mut accepted_bind: Option<[u8; 32]> = None; + for cb in &allowed { + let chal = ownership_challenge_message( + domain_str, + &nonce, + cb, + &subject_raw, + challenge_expiry, + request_hash, + ); + if verify_bip340(&pk0, &signature, &chal).is_ok() { + accepted_bind = Some(*cb); + break; + } + } + let chan_bind = match accepted_bind { + Some(b) => b, + None => { + return Err(ApiError::unauthorized( + "OwnershipProof signature invalid or chan_bind/domain mismatch", + )); + } + }; + + Ok(VerifiedOwnership { + subject_bech32: request_subject.to_string(), + subject_raw, + nonce, + challenge_expiry, + chan_bind, + }) +} + +/// §7.5 `GrantProofJson` on the wire (pull path only). +#[derive(Debug, Clone, Deserialize)] +pub struct GrantProofJson { + #[serde(rename = "type")] + pub proof_type: String, + /// Bech32m `zkgrant` string (§5.2). + pub grant: String, + pub grantee_pk: String, + pub signature: String, +} + +/// Session authority that follows from the verified proof kind. +/// +/// Wire tokens match the interim kernel metadata +/// `x-zkcoins-session-authority` (`ownership` | `grant`) in +/// `node/src/kernel_rpc.rs` / `parse_session_authority`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionAuthority { + Ownership, + Grant, +} + +impl SessionAuthority { + /// Metadata / wire token. Never empty; never a defaulted ownership. + pub const fn as_str(self) -> &'static str { + match self { + SessionAuthority::Ownership => "ownership", + SessionAuthority::Grant => "grant", + } + } +} + +// --------------------------------------------------------------------------- +// Resolved scope (§5.1) — intersection of request and capability +// --------------------------------------------------------------------------- + +/// Normalised pull/grant scope after unbounded-sentinel normalisation. +/// +/// Shape matches `ViewGrant.scope` minus grant-only `expiry`: assets × time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedScope { + pub all_assets: bool, + /// Empty iff `all_assets`. Strictly ascending when non-empty. + pub asset_ids: Vec<[u8; 32]>, + pub not_before: u64, + pub not_after: u64, +} + +impl ResolvedScope { + /// Unbounded sentinel pair: `asset_ids = "*"`, `not_before = 0`, + /// `not_after = 2⁶³−1` (§5.1). + pub fn unbounded() -> Self { + Self { + all_assets: true, + asset_ids: Vec::new(), + not_before: 0, + not_after: SCOPE_NOT_AFTER_UNBOUNDED, + } + } + + /// True only when every dimension uses its unbounded sentinel. + pub fn is_fully_unbounded(&self) -> bool { + self.all_assets && self.not_before == 0 && self.not_after == SCOPE_NOT_AFTER_UNBOUNDED + } +} + +/// Decoded §5.2 `ViewGrant` (payload fields; signature checked separately). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedViewGrant { + pub version: u8, + pub subject: [u8; 32], + pub grantee: [u8; 32], + pub scope: ResolvedScope, + /// Grant usability deadline (unix seconds) — not part of pull scope. + pub expiry: u64, + pub nonce: [u8; 16], + pub op_signature: [u8; 64], + /// `grant_id = H(grant_message)` (§5.2). + pub grant_id: [u8; 32], + /// Preimage of `grant_message` after the domain tag (version…nonce). + pub message_prefix: Vec, +} + +/// Outcome of a successful GrantProof verification (§5.1(b)). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedGrant { + pub subject_bech32: String, + pub subject_raw: [u8; 32], + pub grantee_pk: [u8; 32], + pub nonce: [u8; 32], + pub challenge_expiry: u64, + pub chan_bind: [u8; 32], + /// Capability-only scope from the grant (before request intersection). + pub grant_scope: ResolvedScope, + /// `requested ∩ grant.scope` — what the pull session must record. + pub resolved_scope: ResolvedScope, + pub grant_id: [u8; 32], +} + +/// Process-local map of subject address → published `op_pubkey`. +/// +/// §5.1(b) step 1 requires the subject's **published** op. Until Nostr +/// kind-30420 profile resolution (with the §4.3 address binding) is wired, +/// this directory is the sole API-edge source. It starts **empty**: every +/// GrantProof fails closed at the op-signature step. Entries may be installed +/// only after an authenticated path has bound `op_pubkey` to the subject +/// (tests install fixtures; a future profile-resolution worker writes here). +/// +/// Not a config default and not an operator free-form setting for foreign +/// subjects — a forged entry would make grants verify under an attacker's +/// key (see the §4.3 binding threat). +#[derive(Debug, Default)] +pub struct SubjectOpDirectory { + inner: RwLock>, +} + +impl SubjectOpDirectory { + pub fn new() -> Self { + Self { + inner: RwLock::new(HashMap::new()), + } + } + + /// Install a published op for `subject`. Overwrites any prior entry. + pub fn insert(&self, subject: [u8; 32], op_pubkey: [u8; 32]) { + let mut guard = self.inner.write().expect("subject_ops lock poisoned"); + guard.insert(subject, op_pubkey); + } + + /// Look up the published op. `None` is fail-closed (never a zero key). + pub fn get(&self, subject: &[u8; 32]) -> Option<[u8; 32]> { + let guard = self.inner.read().expect("subject_ops lock poisoned"); + guard.get(subject).copied() + } +} + +/// Node-local revocation set for `grant_id` (§5.2 — forward-only). +#[derive(Debug, Default)] +pub struct RevokedGrantSet { + inner: RwLock>, +} + +impl RevokedGrantSet { + pub fn new() -> Self { + Self { + inner: RwLock::new(HashSet::new()), + } + } + + pub fn revoke(&self, grant_id: [u8; 32]) { + let mut guard = self.inner.write().expect("revoked_grants lock poisoned"); + guard.insert(grant_id); + } + + pub fn contains(&self, grant_id: &[u8; 32]) -> bool { + let guard = self.inner.read().expect("revoked_grants lock poisoned"); + guard.contains(grant_id) + } +} + +/// Verify an OwnershipProof for domains **without** `request_hash` +/// (Pull / Entrust / Revoke — §5.1 L1916 / §7.7). +/// +/// Pure: does not dial the kernel. Body `expiry` is part of the signed +/// preimage (Redeem-body `expiry` normative); a wrong value fails BIP-340. +/// `domain` is endpoint-selected — never taken from the request body. +pub fn verify_simple_ownership_proof( + domain: ChallengeDomain, + request_subject: &str, + challenge: &ChallengeEcho, + proof: &OwnershipProofJson, + public_hosts: &[String], +) -> Result { + if !domain.is_simple() { + return Err(ApiError::internal(format!( + "verify_simple_ownership_proof refuses request_hash domain {:?}", + domain.as_str() + ))); + } + + // Closed capability match — GrantProof is a different type on the wire; + // if the ownership shape carries type=grant, reject here. + let capability = capability_from_wire(&proof.proof_type)?; + require_ownership(capability)?; + + let subject_raw = decode_zk_address(request_subject)?; + let proof_subject_raw = decode_zk_address(&proof.subject)?; + if proof_subject_raw != subject_raw { + return Err(ApiError::unauthorized( + "ownership_proof.subject does not match request subject", + )); + } + + let pk0 = parse_hex32_field(&proof.public_key, "ownership_proof.public_key")?; + let nk_commit = parse_hex32_field(&proof.nk_commit, "ownership_proof.nk_commit")?; + validate_nk_commit_limbs(&nk_commit)?; + let signature = parse_hex64_field(&proof.signature, "ownership_proof.signature")?; + let nonce = parse_hex32_field(&challenge.nonce, "challenge.nonce")?; + let challenge_expiry = parse_u64_decimal(&challenge.expiry) + .map_err(|e| ApiError::malformed(format!("challenge.expiry: {}", e.body.message)))?; + + let expected = address_from_pk0_nk_commit(&pk0, &nk_commit); + if expected != subject_raw { + return Err(ApiError::unauthorized( + "H(Pk0 ‖ nk_commit) does not equal subject address", + )); + } + + if public_hosts.is_empty() { + return Err(ApiError::internal( + "no authoritative public hosts configured for chan_bind (ZKCOINS_PUBLIC_HOST)", + )); + } + let allowed: Vec<[u8; 32]> = public_hosts.iter().map(|h| chan_bind_for_host(h)).collect(); + + let domain_str = domain.as_str(); + let mut accepted_bind: Option<[u8; 32]> = None; + for cb in &allowed { + let chal = pull_challenge_message(domain_str, &nonce, cb, &subject_raw, challenge_expiry); + if verify_bip340(&pk0, &signature, &chal).is_ok() { + accepted_bind = Some(*cb); + break; + } + } + let chan_bind = match accepted_bind { + Some(b) => b, + None => { + return Err(ApiError::unauthorized( + "OwnershipProof signature invalid or chan_bind/domain mismatch", + )); + } + }; + + Ok(VerifiedOwnership { + subject_bech32: request_subject.to_string(), + subject_raw, + nonce, + challenge_expiry, + chan_bind, + }) +} + +/// Verify a pull-domain OwnershipProof (`chal` without `request_hash`). +/// +/// Thin adapter over [`verify_simple_ownership_proof`] for the pull wire shape +/// (top-level `nonce` / `expiry` rather than nested `challenge`). +pub fn verify_pull_ownership_proof( + request_subject: &str, + nonce_hex: &str, + expiry_decimal: &str, + proof: &OwnershipProofJson, + public_hosts: &[String], +) -> Result { + verify_simple_ownership_proof( + ChallengeDomain::Pull, + request_subject, + &ChallengeEcho { + nonce: nonce_hex.to_string(), + expiry: expiry_decimal.to_string(), + }, + proof, + public_hosts, + ) +} + +// --------------------------------------------------------------------------- +// View grant decode + grant_message (§5.2) +// --------------------------------------------------------------------------- + +/// `grant_message = H("zkCoins/v1/Grant" ‖ version ‖ subject ‖ grantee +/// ‖ asset_ids ‖ not_before ‖ not_after ‖ expiry ‖ nonce)`. +/// +/// Field order is the **formula**, not a struct layout. `asset_enc` is the +/// discriminator encoding from [`encode_grant_asset_ids`]. +/// +/// The eight parameters mirror the normative field concatenation of +/// `grant_message` (§5.2). Bundling them into a struct would invite treating +/// that struct's field order as authoritative — the same confusion the spec +/// warns against for `invoice_message` (§4.3). The formula is normative; keep +/// the parameters flat so the call site cannot drift from the byte order. +#[allow(clippy::too_many_arguments)] +pub fn grant_message_digest( + version: u8, + subject: &[u8; 32], + grantee: &[u8; 32], + asset_enc: &[u8], + not_before: u64, + not_after: u64, + expiry: u64, + grant_nonce: &[u8; 16], +) -> ([u8; 32], Vec) { + let mut prefix = Vec::with_capacity(1 + 32 + 32 + asset_enc.len() + 8 + 8 + 8 + 16); + prefix.push(version); + prefix.extend_from_slice(subject); + prefix.extend_from_slice(grantee); + prefix.extend_from_slice(asset_enc); + prefix.extend_from_slice(¬_before.to_be_bytes()); + prefix.extend_from_slice(¬_after.to_be_bytes()); + prefix.extend_from_slice(&expiry.to_be_bytes()); + prefix.extend_from_slice(grant_nonce); + + let mut pre = Vec::with_capacity(GRANT_MESSAGE_TAG.len() + prefix.len()); + pre.extend_from_slice(GRANT_MESSAGE_TAG.as_bytes()); + pre.extend_from_slice(&prefix); + (sha256(&pre), prefix) +} + +/// Decode Bech32m `zkgrant` payload per §5.2. +/// +/// Rejects wrong HRP, unknown version, non-ascending asset lists, truncated +/// or trailing bytes. Does **not** verify the op signature. +pub fn decode_view_grant(bech32m: &str) -> Result { + let checked = CheckedHrpstring::new::(bech32m) + .map_err(|e| ApiError::malformed(format!("grant: invalid Bech32m zkgrant: {e}")))?; + if checked.hrp().as_str() != GRANT_HRP { + return Err(ApiError::malformed(format!( + "grant: expected HRP {GRANT_HRP:?}, got {:?}", + checked.hrp().as_str() + ))); + } + let data: Vec = checked.byte_iter().collect(); + // Minimum: version(1)+subject(32)+grantee(32)+asset disc(1)+times(24)+nonce(16)+sig(64) + // = 170 for wildcard assets. + if data.len() < 170 { + return Err(ApiError::malformed(format!( + "grant: payload too short ({} bytes)", + data.len() + ))); + } + + let mut cur = 0usize; + let version = data[cur]; + cur += 1; + if version != GRANT_VERSION { + return Err(ApiError::malformed(format!( + "grant: unknown version byte 0x{version:02x}; expected 0x{GRANT_VERSION:02x}" + ))); + } + + let mut subject = [0u8; 32]; + subject.copy_from_slice(&data[cur..cur + 32]); + cur += 32; + let mut grantee = [0u8; 32]; + grantee.copy_from_slice(&data[cur..cur + 32]); + cur += 32; + + if cur >= data.len() { + return Err(ApiError::malformed("grant: truncated at asset_ids")); + } + let asset_disc = data[cur]; + cur += 1; + let (all_assets, asset_ids) = match asset_disc { + 0x00 => (true, Vec::new()), + 0x01 => { + if cur + 4 > data.len() { + return Err(ApiError::malformed("grant: truncated asset_ids count")); + } + let mut count_buf = [0u8; 4]; + count_buf.copy_from_slice(&data[cur..cur + 4]); + cur += 4; + let count = u32::from_be_bytes(count_buf) as usize; + if count == 0 { + return Err(ApiError::malformed( + "grant: asset_ids list must be non-empty when not \"*\"", + )); + } + let need = count.checked_mul(32).ok_or_else(|| { + ApiError::malformed("grant: asset_ids count overflows size calculation") + })?; + if cur + need > data.len() { + return Err(ApiError::malformed("grant: truncated asset_ids list")); + } + let mut ids = Vec::with_capacity(count); + for _ in 0..count { + let mut id = [0u8; 32]; + id.copy_from_slice(&data[cur..cur + 32]); + cur += 32; + ids.push(id); + } + for w in ids.windows(2) { + if w[0] >= w[1] { + return Err(ApiError::malformed( + "grant: asset_ids must be strictly ascending", + )); + } + } + (false, ids) + } + other => { + return Err(ApiError::malformed(format!( + "grant: unknown asset_ids discriminator 0x{other:02x}" + ))); + } + }; + + // Fixed tail after assets: not_before + not_after + expiry + nonce + sig. + const TAIL_LEN: usize = 8 + 8 + 8 + 16 + 64; + let remaining = data.len().saturating_sub(cur); + if remaining < TAIL_LEN { + return Err(ApiError::malformed("grant: truncated time/nonce/signature")); + } + if remaining > TAIL_LEN { + return Err(ApiError::malformed("grant: trailing bytes after signature")); + } + + let mut not_before_buf = [0u8; 8]; + not_before_buf.copy_from_slice(&data[cur..cur + 8]); + cur += 8; + let not_before = u64::from_be_bytes(not_before_buf); + let mut not_after_buf = [0u8; 8]; + not_after_buf.copy_from_slice(&data[cur..cur + 8]); + cur += 8; + let not_after = u64::from_be_bytes(not_after_buf); + let mut expiry_buf = [0u8; 8]; + expiry_buf.copy_from_slice(&data[cur..cur + 8]); + cur += 8; + let expiry = u64::from_be_bytes(expiry_buf); + + let mut nonce = [0u8; 16]; + nonce.copy_from_slice(&data[cur..cur + 16]); + cur += 16; + let mut op_signature = [0u8; 64]; + op_signature.copy_from_slice(&data[cur..cur + 64]); + + let asset_enc = encode_grant_asset_ids(all_assets, &asset_ids) + .map_err(|e| ApiError::malformed(format!("grant asset_ids: {}", e.body.message)))?; + let (grant_message, message_prefix) = grant_message_digest( + version, &subject, &grantee, &asset_enc, not_before, not_after, expiry, &nonce, + ); + let grant_id = sha256(&grant_message); + + // message_prefix must be byte-identical to the version…nonce payload slice. + let expected_prefix_len = data.len() - 64; + if message_prefix.as_slice() != &data[..expected_prefix_len] { + return Err(ApiError::internal( + "grant message_prefix recompute diverged from decoded payload", + )); + } + + Ok(DecodedViewGrant { + version, + subject, + grantee, + scope: ResolvedScope { + all_assets, + asset_ids, + not_before, + not_after, + }, + expiry, + nonce, + op_signature, + grant_id, + message_prefix, + }) +} + +/// Encode a view grant as Bech32m `zkgrant` (tests / helpers). +#[cfg(test)] +pub fn encode_view_grant( + subject: &[u8; 32], + grantee: &[u8; 32], + scope: &ResolvedScope, + expiry: u64, + grant_nonce: &[u8; 16], + op_signature: &[u8; 64], +) -> Result { + let asset_enc = encode_grant_asset_ids(scope.all_assets, &scope.asset_ids)?; + let (_msg, prefix) = grant_message_digest( + GRANT_VERSION, + subject, + grantee, + &asset_enc, + scope.not_before, + scope.not_after, + expiry, + grant_nonce, + ); + let mut payload = prefix; + payload.extend_from_slice(op_signature); + let hrp = bech32::Hrp::parse(GRANT_HRP).expect("constant HRP"); + bech32::encode::(hrp, &payload) + .map_err(|e| ApiError::internal(format!("zkgrant encode failed: {e}"))) +} + +// --------------------------------------------------------------------------- +// Scope intersection (§5.1) +// --------------------------------------------------------------------------- + +/// Resolve `requested_scope ∩ grant.scope` per §5.1. +/// +/// - Time windows always intersect (`max` lower / `min` upper, inclusive). +/// - `asset_ids = "*"` against a narrower grant is **clamped** (silent). +/// - An **explicit** requested `asset_id` not in the grant → `403 scope_exceeded` +/// (not silent removal of the foreign id). +/// - Empty intersection (empty assets after clamp, or `not_before > not_after`) +/// → `403 scope_exceeded`. +pub fn intersect_scopes( + requested: &ResolvedScope, + grant: &ResolvedScope, +) -> Result { + let not_before = requested.not_before.max(grant.not_before); + let not_after = requested.not_after.min(grant.not_after); + if not_before > not_after { + return Err(ApiError::scope_exceeded( + "resolved scope time window is empty (requested ∩ grant)", + )); + } + + let (all_assets, asset_ids) = match (requested.all_assets, grant.all_assets) { + (true, true) => (true, Vec::new()), + (true, false) => { + // Clamp * to the grant's explicit set. + if grant.asset_ids.is_empty() { + return Err(ApiError::scope_exceeded( + "resolved scope asset intersection is empty", + )); + } + (false, grant.asset_ids.clone()) + } + (false, true) => { + if requested.asset_ids.is_empty() { + return Err(ApiError::scope_exceeded( + "resolved scope asset intersection is empty", + )); + } + (false, requested.asset_ids.clone()) + } + (false, false) => { + // Every explicitly named requested id must be in the grant. + for id in &requested.asset_ids { + if !grant.asset_ids.iter().any(|g| g == id) { + return Err(ApiError::scope_exceeded( + "request names an asset_id outside grant.scope.asset_ids", + )); + } + } + if requested.asset_ids.is_empty() { + return Err(ApiError::scope_exceeded( + "resolved scope asset intersection is empty", + )); + } + (false, requested.asset_ids.clone()) + } + }; + + Ok(ResolvedScope { + all_assets, + asset_ids, + not_before, + not_after, + }) +} + +// --------------------------------------------------------------------------- +// GrantProof verification (§5.1(b) + §5.2) +// --------------------------------------------------------------------------- + +/// Environment / policy inputs for GrantProof verification. +/// +/// These are **not** part of the proof under examination: they are the node's +/// authoritative host list, wall-clock for grant expiry, and local revocation +/// set. Proof-carrying fields stay as distinct parameters on +/// [`verify_grant_proof`]. +#[derive(Debug, Clone, Copy)] +pub struct GrantVerificationContext<'a> { + /// Authoritative public hosts for §5.1 `chan_bind` (config only). + pub public_hosts: &'a [String], + /// Unix seconds used for grant `expiry` (inclusive upper bound). + pub now: u64, + /// Node-local revocation set (`grant_id` → refuse). + pub revoked: &'a RevokedGrantSet, +} + +/// Verify a pull-domain GrantProof **without** calling the kernel. +/// +/// Normative order (§5.1(b)): +/// 1. Decode `zkgrant`; recompute `grant_message` (fixed field concatenation); +/// verify BIP-340 under the subject's **published** `op_pubkey`. +/// 2. `grantee_pk == grant.grantee` and BIP-340 over `chal` under grantee `D`. +/// 3. Grant not expired (`now ≤ grant.expiry`) and not revoked. +/// 4. Resolve `requested ∩ grant.scope` (empty / explicit foreign asset → 403). +/// +/// Pure: a failed check never dials the kernel and cannot burn the nonce. +pub fn verify_grant_proof( + nonce_hex: &str, + expiry_decimal: &str, + proof: &GrantProofJson, + op_pubkey: &[u8; 32], + requested_scope: &ResolvedScope, + ctx: &GrantVerificationContext<'_>, +) -> Result { + if proof.proof_type != "grant" { + return Err(ApiError::unauthorized(format!( + "GrantProof type must be \"grant\", got {:?}", + proof.proof_type + ))); + } + + // ---- decode grant (structural) ---- + let grant = decode_view_grant(&proof.grant)?; + + // ---- (1) op signature over grant_message ---- + let asset_enc = encode_grant_asset_ids(grant.scope.all_assets, &grant.scope.asset_ids)?; + let (grant_message, _) = grant_message_digest( + grant.version, + &grant.subject, + &grant.grantee, + &asset_enc, + grant.scope.not_before, + grant.scope.not_after, + grant.expiry, + &grant.nonce, + ); + verify_bip340_with_message( + op_pubkey, + &grant.op_signature, + &grant_message, + "grant op_pubkey is not a valid x-only pubkey", + "grant op_signature is not a valid BIP-340 signature", + "grant op signature invalid (wrong signer, manipulated signature, or grant_message field order)", + )?; + + // ---- (2) grantee identity + chal signature ---- + let grantee_pk = parse_hex32_field(&proof.grantee_pk, "grant_proof.grantee_pk")?; + if grantee_pk != grant.grantee { + return Err(ApiError::unauthorized( + "grant_proof.grantee_pk does not equal grant.grantee", + )); + } + + let challenge_nonce = parse_hex32_field(nonce_hex, "challenge.nonce")?; + let challenge_expiry = parse_u64_decimal(expiry_decimal) + .map_err(|e| ApiError::malformed(format!("challenge.expiry: {}", e.body.message)))?; + + if ctx.public_hosts.is_empty() { + return Err(ApiError::internal( + "no authoritative public hosts configured for chan_bind (ZKCOINS_PUBLIC_HOST)", + )); + } + let allowed: Vec<[u8; 32]> = ctx + .public_hosts + .iter() + .map(|h| chan_bind_for_host(h)) + .collect(); + let grantee_sig = parse_hex64_field(&proof.signature, "grant_proof.signature")?; + + let domain_str = ChallengeDomain::Pull.as_str(); + let mut accepted_bind: Option<[u8; 32]> = None; + for cb in &allowed { + let chal = pull_challenge_message( + domain_str, + &challenge_nonce, + cb, + &grant.subject, + challenge_expiry, + ); + if verify_bip340_with_message( + &grantee_pk, + &grantee_sig, + &chal, + "grant_proof.grantee_pk is not a valid x-only pubkey", + "grant_proof.signature is not a valid BIP-340 signature", + "GrantProof grantee signature invalid or chan_bind/domain mismatch", + ) + .is_ok() + { + accepted_bind = Some(*cb); + break; + } + } + let chan_bind = match accepted_bind { + Some(b) => b, + None => { + return Err(ApiError::unauthorized( + "GrantProof grantee signature invalid or chan_bind/domain mismatch", + )); + } + }; + + // ---- (3) expiry + revocation ---- + // `now > expiry` is unusable. Equality at the exact second remains valid + // (inclusive upper bound on usability). + if ctx.now > grant.expiry { + return Err(ApiError::unauthorized( + "view grant has expired (scope.expiry is in the past)", + )); + } + if ctx.revoked.contains(&grant.grant_id) { + return Err(ApiError::unauthorized( + "view grant has been revoked (grant_id is in the node revocation set)", + )); + } + + // ---- (4) scope intersection ---- + let resolved_scope = intersect_scopes(requested_scope, &grant.scope)?; + + let subject_bech32 = encode_zk_address_public(&grant.subject)?; + + Ok(VerifiedGrant { + subject_bech32, + subject_raw: grant.subject, + grantee_pk, + nonce: challenge_nonce, + challenge_expiry, + chan_bind, + grant_scope: grant.scope, + resolved_scope, + grant_id: grant.grant_id, + }) +} + +/// Encode 32 raw address bytes as Bech32m `zk` (public helper for grant path). +pub fn encode_zk_address_public(raw: &[u8; 32]) -> Result { + let hrp = bech32::Hrp::parse(ADDRESS_HRP) + .map_err(|e| ApiError::internal(format!("address HRP parse: {e}")))?; + bech32::encode::(hrp, raw) + .map_err(|e| ApiError::internal(format!("address encode failed: {e}"))) +} + +/// Hex-encode a 32-byte digest (re-export convenience for handlers). +pub fn hex32(bytes: &[u8; 32]) -> String { + encode_hex(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::secp256k1::{Keypair, SecretKey}; + + fn sample_sk_pk() -> (SecretKey, [u8; 32]) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x42u8; 32]).expect("32-byte secret"); + let kp = Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = kp.x_only_public_key(); + (sk, xonly.serialize()) + } + + fn sign_chal(sk: &SecretKey, chal: &[u8; 32]) -> [u8; 64] { + let secp = Secp256k1::new(); + let kp = Keypair::from_secret_key(&secp, sk); + let msg = Message::from_digest_slice(chal).expect("32-byte digest"); + let sig = secp.sign_schnorr_no_aux_rand(&msg, &kp); + let bytes = sig.as_ref(); + let mut out = [0u8; 64]; + out.copy_from_slice(bytes); + out + } + + fn fixture_identity() -> (SecretKey, [u8; 32], [u8; 32], [u8; 32], String) { + let (sk, pk0) = sample_sk_pk(); + // Canonical Goldilocks limbs (all zeros) — valid nk_commit encoding. + let nk_commit = [0u8; 32]; + let subject_raw = address_from_pk0_nk_commit(&pk0, &nk_commit); + let subject_bech = encode_zk_address(&subject_raw); + (sk, pk0, nk_commit, subject_raw, subject_bech) + } + + #[test] + fn domain_strings_match_node_challenge_action() { + assert_eq!(ChallengeDomain::Pull.as_str(), "zkCoins/v1/PullChallenge"); + assert_eq!( + ChallengeDomain::AttestBalance.as_str(), + "zkCoins/v1/AttestBalanceChallenge" + ); + assert_eq!( + ChallengeDomain::IssueGrant.as_str(), + "zkCoins/v1/IssueGrantChallenge" + ); + assert_eq!( + ChallengeDomain::Entrust.as_str(), + "zkCoins/v1/EntrustChallenge" + ); + assert_eq!( + ChallengeDomain::Revoke.as_str(), + "zkCoins/v1/RevokeChallenge" + ); + assert_ne!( + ChallengeDomain::AttestBalance.as_str(), + ChallengeDomain::IssueGrant.as_str() + ); + assert_ne!( + ChallengeDomain::Pull.as_str(), + ChallengeDomain::AttestBalance.as_str() + ); + // Bootstrap domains are pairwise distinct from each other and from Pull + // so a proof cannot be retargeted across actions (§7.7). + assert_ne!( + ChallengeDomain::Entrust.as_str(), + ChallengeDomain::Revoke.as_str() + ); + assert_ne!( + ChallengeDomain::Entrust.as_str(), + ChallengeDomain::Pull.as_str() + ); + assert_ne!( + ChallengeDomain::Revoke.as_str(), + ChallengeDomain::Pull.as_str() + ); + assert!(ChallengeDomain::Entrust.is_simple()); + assert!(ChallengeDomain::Revoke.is_simple()); + assert!(ChallengeDomain::Pull.is_simple()); + assert!(!ChallengeDomain::AttestBalance.is_simple()); + assert!(!ChallengeDomain::IssueGrant.is_simple()); + } + + #[test] + fn entrust_domain_rejects_revoke_signed_proof() { + let (sk, pk0, nkc, subject_raw, subject_bech) = fixture_identity(); + let host = "node.example.com"; + let nonce = [0xCCu8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + // Sign under Revoke; redeem under Entrust. + let chal = pull_challenge_message( + ChallengeDomain::Revoke.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = sign_chal(&sk, &chal); + let err = verify_simple_ownership_proof( + ChallengeDomain::Entrust, + &subject_bech, + &ChallengeEcho { + nonce: encode_hex(&nonce), + expiry: expiry.to_string(), + }, + &OwnershipProofJson { + proof_type: "ownership".into(), + subject: subject_bech.clone(), + public_key: encode_hex(&pk0), + nk_commit: encode_hex(&nkc), + signature: encode_hex(&sig), + }, + &[host.to_string()], + ) + .expect_err("revoke-signed proof must not authorise entrust"); + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn revoke_domain_rejects_entrust_signed_proof() { + let (sk, pk0, nkc, subject_raw, subject_bech) = fixture_identity(); + let host = "node.example.com"; + let nonce = [0xDDu8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Entrust.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = sign_chal(&sk, &chal); + let err = verify_simple_ownership_proof( + ChallengeDomain::Revoke, + &subject_bech, + &ChallengeEcho { + nonce: encode_hex(&nonce), + expiry: expiry.to_string(), + }, + &OwnershipProofJson { + proof_type: "ownership".into(), + subject: subject_bech.clone(), + public_key: encode_hex(&pk0), + nk_commit: encode_hex(&nkc), + signature: encode_hex(&sig), + }, + &[host.to_string()], + ) + .expect_err("entrust-signed proof must not authorise revoke"); + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn pull_ownership_proof_verifies_without_request_hash() { + let (sk, pk0, nkc, subject_raw, subject_bech) = fixture_identity(); + let host = "node.example.com"; + let nonce = [0xAAu8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Pull.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = sign_chal(&sk, &chal); + let verified = verify_pull_ownership_proof( + &subject_bech, + &encode_hex(&nonce), + &expiry.to_string(), + &OwnershipProofJson { + proof_type: "ownership".into(), + subject: subject_bech.clone(), + public_key: encode_hex(&pk0), + nk_commit: encode_hex(&nkc), + signature: encode_hex(&sig), + }, + &[host.to_string()], + ) + .expect("valid pull proof"); + assert_eq!(verified.chan_bind, cb); + assert_eq!(verified.nonce, nonce); + } + + #[test] + fn pull_ownership_wrong_expiry_is_unauthorized() { + let (sk, pk0, nkc, subject_raw, subject_bech) = fixture_identity(); + let host = "node.example.com"; + let nonce = [0xBBu8; 32]; + let signed_expiry = 100u64; + let presented_expiry = 999u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Pull.as_str(), + &nonce, + &cb, + &subject_raw, + signed_expiry, + ); + let sig = sign_chal(&sk, &chal); + let err = verify_pull_ownership_proof( + &subject_bech, + &encode_hex(&nonce), + &presented_expiry.to_string(), + &OwnershipProofJson { + proof_type: "ownership".into(), + subject: subject_bech.clone(), + public_key: encode_hex(&pk0), + nk_commit: encode_hex(&nkc), + signature: encode_hex(&sig), + }, + &[host.to_string()], + ) + .expect_err("altered expiry"); + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn session_authority_wire_tokens_match_node_metadata() { + // node `parse_session_authority`: "ownership" | "grant" only. + assert_eq!(SessionAuthority::Ownership.as_str(), "ownership"); + assert_eq!(SessionAuthority::Grant.as_str(), "grant"); + assert_ne!( + SessionAuthority::Ownership.as_str(), + SessionAuthority::Grant.as_str() + ); + } + + #[test] + fn valid_ownership_proof_verifies_under_endpoint_domain() { + let (sk, pk0, nkc, subject_raw, subject_bech) = fixture_identity(); + let host = "node.example.com"; + let nonce = [0xAAu8; 32]; + let expiry = 1_700_000_060u64; + let request_hash = [0x11u8; 32]; + let cb = chan_bind_for_host(host); + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = sign_chal(&sk, &chal); + + let verified = verify_ownership_proof( + ChallengeDomain::AttestBalance, + &subject_bech, + &ChallengeEcho { + nonce: encode_hex(&nonce), + expiry: expiry.to_string(), + }, + &OwnershipProofJson { + proof_type: "ownership".into(), + subject: subject_bech.clone(), + public_key: encode_hex(&pk0), + nk_commit: encode_hex(&nkc), + signature: encode_hex(&sig), + }, + &request_hash, + &[host.to_string()], + ) + .expect("valid proof"); + assert_eq!(verified.chan_bind, cb); + assert_eq!(verified.subject_raw, subject_raw); + assert_eq!(verified.nonce, nonce); + } + + #[test] + fn wrong_domain_is_unauthorized() { + let (sk, pk0, nkc, subject_raw, subject_bech) = fixture_identity(); + let host = "node.example.com"; + let nonce = [0xBBu8; 32]; + let expiry = 99u64; + let request_hash = [0x22u8; 32]; + let cb = chan_bind_for_host(host); + // Sign under AttestBalance… + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = sign_chal(&sk, &chal); + // …verify under IssueGrant → must fail. + let err = verify_ownership_proof( + ChallengeDomain::IssueGrant, + &subject_bech, + &ChallengeEcho { + nonce: encode_hex(&nonce), + expiry: expiry.to_string(), + }, + &OwnershipProofJson { + proof_type: "ownership".into(), + subject: subject_bech.clone(), + public_key: encode_hex(&pk0), + nk_commit: encode_hex(&nkc), + signature: encode_hex(&sig), + }, + &request_hash, + &[host.to_string()], + ) + .expect_err("cross-domain"); + assert_eq!(err.body.error, "unauthorized"); + assert_eq!(err.status, axum::http::StatusCode::UNAUTHORIZED); + } + + #[test] + fn validate_resolved_scope_rejects_non_ascending_and_empty_interval() { + let a = [0x01u8; 32]; + let mut b = [0x02u8; 32]; + b[0] = 0x02; + // Descending + let s = ResolvedScope { + all_assets: false, + asset_ids: vec![b, a], + not_before: 0, + not_after: SCOPE_NOT_AFTER_UNBOUNDED, + }; + let err = validate_resolved_scope(&s).expect_err("descending"); + assert_eq!(err.body.error, "malformed_request"); + assert!(err.body.message.contains("ascending")); + + // Duplicate + let s = ResolvedScope { + all_assets: false, + asset_ids: vec![a, a], + not_before: 0, + not_after: SCOPE_NOT_AFTER_UNBOUNDED, + }; + assert!(validate_resolved_scope(&s).is_err()); + + // Empty interval + let s = ResolvedScope { + all_assets: true, + asset_ids: vec![], + not_before: 100, + not_after: 50, + }; + let err = validate_resolved_scope(&s).expect_err("empty interval"); + assert_eq!(err.body.error, "malformed_request"); + assert!(err.body.message.contains("empty") || err.body.message.contains("not_before")); + + // Valid ascending + let s = ResolvedScope { + all_assets: false, + asset_ids: vec![a, b], + not_before: 10, + not_after: 20, + }; + assert!(validate_resolved_scope(&s).is_ok()); + } + + #[test] + fn grant_proof_type_is_unauthorized() { + let err = verify_ownership_proof( + ChallengeDomain::AttestBalance, + &encode_zk_address(&[0u8; 32]), + &ChallengeEcho { + nonce: encode_hex(&[1u8; 32]), + expiry: "1".into(), + }, + &OwnershipProofJson { + proof_type: "grant".into(), + subject: encode_zk_address(&[0u8; 32]), + public_key: encode_hex(&[0u8; 32]), + nk_commit: encode_hex(&[0u8; 32]), + signature: encode_hex(&[0u8; 64]), + }, + &[0u8; 32], + &["h.example".into()], + ) + .expect_err("grant"); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("GrantProof"), + "message must name GrantProof: {}", + err.body.message + ); + } + + #[test] + fn wrong_chan_bind_is_unauthorized() { + let (sk, pk0, nkc, subject_raw, subject_bech) = fixture_identity(); + let signed_host = "signed.example.com"; + let serve_host = "other.example.com"; + let nonce = [0xCCu8; 32]; + let expiry = 50u64; + let request_hash = [0x33u8; 32]; + let cb = chan_bind_for_host(signed_host); + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = sign_chal(&sk, &chal); + let err = verify_ownership_proof( + ChallengeDomain::AttestBalance, + &subject_bech, + &ChallengeEcho { + nonce: encode_hex(&nonce), + expiry: expiry.to_string(), + }, + &OwnershipProofJson { + proof_type: "ownership".into(), + subject: subject_bech.clone(), + public_key: encode_hex(&pk0), + nk_commit: encode_hex(&nkc), + signature: encode_hex(&sig), + }, + &request_hash, + &[serve_host.to_string()], + ) + .expect_err("wrong host"); + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn wrong_request_hash_is_unauthorized() { + let (sk, pk0, nkc, subject_raw, subject_bech) = fixture_identity(); + let host = "node.example.com"; + let nonce = [0xDDu8; 32]; + let expiry = 60u64; + let signed_hash = [0x44u8; 32]; + let presented_hash = [0x55u8; 32]; + let cb = chan_bind_for_host(host); + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &signed_hash, + ); + let sig = sign_chal(&sk, &chal); + let err = verify_ownership_proof( + ChallengeDomain::AttestBalance, + &subject_bech, + &ChallengeEcho { + nonce: encode_hex(&nonce), + expiry: expiry.to_string(), + }, + &OwnershipProofJson { + proof_type: "ownership".into(), + subject: subject_bech.clone(), + public_key: encode_hex(&pk0), + nk_commit: encode_hex(&nkc), + signature: encode_hex(&sig), + }, + &presented_hash, + &[host.to_string()], + ) + .expect_err("body changed after sign"); + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn ceiling_encoding_both_or_neither() { + assert_eq!(ceiling_encoding(None, None).unwrap(), vec![0x00]); + let nav = [0xABu8; 32]; + let enc = ceiling_encoding(Some(&nav), Some(7)).unwrap(); + assert_eq!(enc[0], 0x01); + assert_eq!(&enc[1..33], &nav); + assert_eq!(&enc[33..], &7u64.to_be_bytes()); + assert!(ceiling_encoding(Some(&nav), None).is_err()); + assert!(ceiling_encoding(None, Some(1)).is_err()); + } + + #[test] + fn scope_not_after_unbounded_is_i64_max_bit_pattern() { + assert_eq!(SCOPE_NOT_AFTER_UNBOUNDED, i64::MAX as u64); + } + + // ----------------------------------------------------------------------- + // GrantProof verification (§5.1(b) / §5.2) — pure, real BIP-340 + // ----------------------------------------------------------------------- + + fn sample_op_sk_pk() -> (SecretKey, [u8; 32]) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x55u8; 32]).expect("op secret"); + let kp = Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = kp.x_only_public_key(); + (sk, xonly.serialize()) + } + + fn sample_grantee_sk_pk() -> (SecretKey, [u8; 32]) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x66u8; 32]).expect("grantee secret"); + let kp = Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = kp.x_only_public_key(); + (sk, xonly.serialize()) + } + + /// Build a valid signed zkgrant for tests. + fn signed_grant( + op_sk: &SecretKey, + subject: &[u8; 32], + grantee: &[u8; 32], + scope: &ResolvedScope, + expiry: u64, + grant_nonce: &[u8; 16], + ) -> (String, [u8; 32], [u8; 32]) { + let asset_enc = encode_grant_asset_ids(scope.all_assets, &scope.asset_ids).unwrap(); + let (grant_message, _prefix) = grant_message_digest( + GRANT_VERSION, + subject, + grantee, + &asset_enc, + scope.not_before, + scope.not_after, + expiry, + grant_nonce, + ); + let grant_id = sha256(&grant_message); + let op_sig = sign_chal(op_sk, &grant_message); + let bech = + encode_view_grant(subject, grantee, scope, expiry, grant_nonce, &op_sig).unwrap(); + (bech, grant_message, grant_id) + } + + fn grant_fixture() -> GrantFixture { + let (op_sk, op_pk) = sample_op_sk_pk(); + let (grantee_sk, grantee_pk) = sample_grantee_sk_pk(); + // Subject is an independent address digest (not derived from op). + let subject = [0x10u8; 32]; + let scope = ResolvedScope { + all_assets: false, + asset_ids: vec![[0x01u8; 32], [0x02u8; 32]], + not_before: 1_000, + not_after: 2_000_000_000, + }; + let grant_expiry = 1_800_000_000u64; + let grant_nonce = [0x77u8; 16]; + let (bech, grant_message, grant_id) = signed_grant( + &op_sk, + &subject, + &grantee_pk, + &scope, + grant_expiry, + &grant_nonce, + ); + GrantFixture { + op_sk, + op_pk, + grantee_sk, + grantee_pk, + subject, + scope, + grant_expiry, + grant_nonce, + bech, + grant_message, + grant_id, + } + } + + struct GrantFixture { + op_sk: SecretKey, + op_pk: [u8; 32], + grantee_sk: SecretKey, + grantee_pk: [u8; 32], + subject: [u8; 32], + scope: ResolvedScope, + grant_expiry: u64, + grant_nonce: [u8; 16], + bech: String, + grant_message: [u8; 32], + grant_id: [u8; 32], + } + + fn sign_grantee_chal( + f: &GrantFixture, + host: &str, + nonce: &[u8; 32], + chal_expiry: u64, + ) -> [u8; 64] { + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Pull.as_str(), + nonce, + &cb, + &f.subject, + chal_expiry, + ); + sign_chal(&f.grantee_sk, &chal) + } + + fn grant_ctx<'a>( + hosts: &'a [String], + now: u64, + revoked: &'a RevokedGrantSet, + ) -> GrantVerificationContext<'a> { + GrantVerificationContext { + public_hosts: hosts, + now, + revoked, + } + } + + #[test] + fn grant_proof_valid_verifies_and_intersects_scope() { + let f = grant_fixture(); + let host = "node.example.com"; + let hosts = [host.to_string()]; + let nonce = [0xAAu8; 32]; + let chal_expiry = 1_700_000_060u64; + let now = 1_700_000_000u64; + let revoked = RevokedGrantSet::new(); + let grantee_sig = sign_grantee_chal(&f, host, &nonce, chal_expiry); + + // Request asks for more assets + wider time than the grant. + let requested = ResolvedScope { + all_assets: true, + asset_ids: Vec::new(), + not_before: 0, + not_after: SCOPE_NOT_AFTER_UNBOUNDED, + }; + let verified = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: f.bech.clone(), + grantee_pk: encode_hex(&f.grantee_pk), + signature: encode_hex(&grantee_sig), + }, + &f.op_pk, + &requested, + &grant_ctx(&hosts, now, &revoked), + ) + .expect("valid grant proof"); + assert_eq!(verified.subject_raw, f.subject); + assert_eq!(verified.grant_id, f.grant_id); + assert_eq!(verified.resolved_scope, f.scope); + assert!( + !verified.resolved_scope.is_fully_unbounded(), + "grant session must not receive unbounded scope when grant is scoped" + ); + } + + #[test] + fn grant_proof_manipulated_op_signature_is_unauthorized() { + let f = grant_fixture(); + let host = "node.example.com"; + let hosts = [host.to_string()]; + let nonce = [0xBBu8; 32]; + let chal_expiry = 1_700_000_060u64; + let revoked = RevokedGrantSet::new(); + let grantee_sig = sign_grantee_chal(&f, host, &nonce, chal_expiry); + // Flip one byte of the trailing op signature inside the bech payload. + let mut bad_sig = { + let decoded = decode_view_grant(&f.bech).unwrap(); + decoded.op_signature + }; + bad_sig[0] ^= 0x01; + let bad_bech = encode_view_grant( + &f.subject, + &f.grantee_pk, + &f.scope, + f.grant_expiry, + &f.grant_nonce, + &bad_sig, + ) + .unwrap(); + let err = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: bad_bech, + grantee_pk: encode_hex(&f.grantee_pk), + signature: encode_hex(&grantee_sig), + }, + &f.op_pk, + &ResolvedScope::unbounded(), + &grant_ctx(&hosts, 1_700_000_000, &revoked), + ) + .expect_err("manipulated op signature"); + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn grant_proof_wrong_op_signer_is_unauthorized() { + let f = grant_fixture(); + let host = "node.example.com"; + let hosts = [host.to_string()]; + let nonce = [0xCCu8; 32]; + let chal_expiry = 1_700_000_060u64; + let revoked = RevokedGrantSet::new(); + let grantee_sig = sign_grantee_chal(&f, host, &nonce, chal_expiry); + // Present a different published op_pubkey than the one that signed. + let (_other_sk, other_op_pk) = { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x99u8; 32]).unwrap(); + let kp = Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = kp.x_only_public_key(); + (sk, xonly.serialize()) + }; + let err = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: f.bech.clone(), + grantee_pk: encode_hex(&f.grantee_pk), + signature: encode_hex(&grantee_sig), + }, + &other_op_pk, + &ResolvedScope::unbounded(), + &grant_ctx(&hosts, 1_700_000_000, &revoked), + ) + .expect_err("wrong op signer"); + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn grant_message_swapped_field_order_fails_op_verify() { + // Normative formula is version‖subject‖grantee‖assets‖… — not struct order. + // Sign under swapped subject/grantee in the preimage; verify with correct order. + let f = grant_fixture(); + let asset_enc = encode_grant_asset_ids(f.scope.all_assets, &f.scope.asset_ids).unwrap(); + // Swapped: grantee before subject in the tagged preimage. + let mut wrong_pre = Vec::new(); + wrong_pre.extend_from_slice(GRANT_MESSAGE_TAG.as_bytes()); + wrong_pre.push(GRANT_VERSION); + wrong_pre.extend_from_slice(&f.grantee_pk); // swapped + wrong_pre.extend_from_slice(&f.subject); // swapped + wrong_pre.extend_from_slice(&asset_enc); + wrong_pre.extend_from_slice(&f.scope.not_before.to_be_bytes()); + wrong_pre.extend_from_slice(&f.scope.not_after.to_be_bytes()); + wrong_pre.extend_from_slice(&f.grant_expiry.to_be_bytes()); + wrong_pre.extend_from_slice(&f.grant_nonce); + let wrong_msg: [u8; 32] = sha256(&wrong_pre); + let wrong_sig = sign_chal(&f.op_sk, &wrong_msg); + // Encode a payload whose prefix is the **correct** order (as a real grant + // wire would carry) but signature was over the swapped preimage. + let bad_bech = encode_view_grant( + &f.subject, + &f.grantee_pk, + &f.scope, + f.grant_expiry, + &f.grant_nonce, + &wrong_sig, + ) + .unwrap(); + let host = "node.example.com"; + let hosts = [host.to_string()]; + let nonce = [0xDDu8; 32]; + let chal_expiry = 1_700_000_060u64; + let revoked = RevokedGrantSet::new(); + let grantee_sig = sign_grantee_chal(&f, host, &nonce, chal_expiry); + let err = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: bad_bech, + grantee_pk: encode_hex(&f.grantee_pk), + signature: encode_hex(&grantee_sig), + }, + &f.op_pk, + &ResolvedScope::unbounded(), + &grant_ctx(&hosts, 1_700_000_000, &revoked), + ) + .expect_err("swapped grant_message field order"); + assert_eq!(err.body.error, "unauthorized"); + // Correct-order signature still verifies against the normative digest. + assert_ne!(wrong_msg, f.grant_message); + } + + #[test] + fn grant_proof_expired_is_unauthorized() { + let f = grant_fixture(); + let host = "node.example.com"; + let hosts = [host.to_string()]; + let nonce = [0xEEu8; 32]; + let chal_expiry = 1_700_000_060u64; + let revoked = RevokedGrantSet::new(); + let grantee_sig = sign_grantee_chal(&f, host, &nonce, chal_expiry); + // now strictly after grant.expiry + let now = f.grant_expiry + 1; + let err = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: f.bech.clone(), + grantee_pk: encode_hex(&f.grantee_pk), + signature: encode_hex(&grantee_sig), + }, + &f.op_pk, + &ResolvedScope::unbounded(), + &grant_ctx(&hosts, now, &revoked), + ) + .expect_err("expired grant"); + assert_eq!(err.body.error, "unauthorized"); + assert!( + err.body.message.contains("expired"), + "message: {}", + err.body.message + ); + } + + #[test] + fn grant_proof_explicit_asset_outside_grant_is_scope_exceeded() { + let f = grant_fixture(); + let host = "node.example.com"; + let hosts = [host.to_string()]; + let nonce = [0xF1u8; 32]; + let chal_expiry = 1_700_000_060u64; + let revoked = RevokedGrantSet::new(); + let grantee_sig = sign_grantee_chal(&f, host, &nonce, chal_expiry); + let foreign_asset = [0xFFu8; 32]; + let requested = ResolvedScope { + all_assets: false, + asset_ids: vec![foreign_asset], + not_before: 0, + not_after: SCOPE_NOT_AFTER_UNBOUNDED, + }; + let err = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: f.bech.clone(), + grantee_pk: encode_hex(&f.grantee_pk), + signature: encode_hex(&grantee_sig), + }, + &f.op_pk, + &requested, + &grant_ctx(&hosts, 1_700_000_000, &revoked), + ) + .expect_err("asset outside grant"); + assert_eq!(err.body.error, "scope_exceeded"); + assert_eq!(err.status, axum::http::StatusCode::FORBIDDEN); + } + + #[test] + fn scope_request_wider_than_grant_is_clamped_to_intersection() { + let grant = ResolvedScope { + all_assets: false, + asset_ids: vec![[0x01u8; 32]], + not_before: 100, + not_after: 200, + }; + let requested = ResolvedScope { + all_assets: true, + asset_ids: Vec::new(), + not_before: 0, + not_after: SCOPE_NOT_AFTER_UNBOUNDED, + }; + let resolved = intersect_scopes(&requested, &grant).unwrap(); + assert_eq!(resolved, grant); + assert!(!resolved.is_fully_unbounded()); + } + + #[test] + fn scope_request_narrower_than_grant_keeps_request() { + let grant = ResolvedScope { + all_assets: true, + asset_ids: Vec::new(), + not_before: 0, + not_after: SCOPE_NOT_AFTER_UNBOUNDED, + }; + let requested = ResolvedScope { + all_assets: false, + asset_ids: vec![[0xAAu8; 32]], + not_before: 50, + not_after: 60, + }; + let resolved = intersect_scopes(&requested, &grant).unwrap(); + assert_eq!(resolved, requested); + } + + #[test] + fn scope_partial_time_overlap_intersects() { + let grant = ResolvedScope { + all_assets: true, + asset_ids: Vec::new(), + not_before: 100, + not_after: 200, + }; + let requested = ResolvedScope { + all_assets: true, + asset_ids: Vec::new(), + not_before: 150, + not_after: 250, + }; + let resolved = intersect_scopes(&requested, &grant).unwrap(); + assert_eq!(resolved.not_before, 150); + assert_eq!(resolved.not_after, 200); + } + + #[test] + fn scope_disjoint_time_is_scope_exceeded() { + let grant = ResolvedScope { + all_assets: true, + asset_ids: Vec::new(), + not_before: 100, + not_after: 200, + }; + let requested = ResolvedScope { + all_assets: true, + asset_ids: Vec::new(), + not_before: 201, + not_after: 300, + }; + let err = intersect_scopes(&requested, &grant).expect_err("disjoint"); + assert_eq!(err.body.error, "scope_exceeded"); + } + + #[test] + fn grant_based_resolved_scope_never_unbounded_when_grant_is_scoped() { + let grant = ResolvedScope { + all_assets: false, + asset_ids: vec![[0x01u8; 32]], + not_before: 0, + not_after: SCOPE_NOT_AFTER_UNBOUNDED, + }; + let requested = ResolvedScope::unbounded(); + let resolved = intersect_scopes(&requested, &grant).unwrap(); + assert!( + !resolved.is_fully_unbounded(), + "intersection with a scoped grant must not be fully unbounded" + ); + assert!(!resolved.all_assets); + } + + #[test] + fn grant_proof_revoked_is_unauthorized() { + let f = grant_fixture(); + let host = "node.example.com"; + let hosts = [host.to_string()]; + let nonce = [0xF2u8; 32]; + let chal_expiry = 1_700_000_060u64; + let grantee_sig = sign_grantee_chal(&f, host, &nonce, chal_expiry); + let revoked = RevokedGrantSet::new(); + revoked.revoke(f.grant_id); + let err = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: f.bech.clone(), + grantee_pk: encode_hex(&f.grantee_pk), + signature: encode_hex(&grantee_sig), + }, + &f.op_pk, + &ResolvedScope::unbounded(), + &grant_ctx(&hosts, 1_700_000_000, &revoked), + ) + .expect_err("revoked"); + assert_eq!(err.body.error, "unauthorized"); + assert!(err.body.message.contains("revoked")); + } + + #[test] + fn grant_proof_grantee_mismatch_is_unauthorized() { + let f = grant_fixture(); + let host = "node.example.com"; + let hosts = [host.to_string()]; + let nonce = [0xF3u8; 32]; + let chal_expiry = 1_700_000_060u64; + let revoked = RevokedGrantSet::new(); + let grantee_sig = sign_grantee_chal(&f, host, &nonce, chal_expiry); + let other_pk = [0x88u8; 32]; + let err = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: f.bech.clone(), + grantee_pk: encode_hex(&other_pk), + signature: encode_hex(&grantee_sig), + }, + &f.op_pk, + &ResolvedScope::unbounded(), + &grant_ctx(&hosts, 1_700_000_000, &revoked), + ) + .expect_err("grantee mismatch"); + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn grant_proof_manipulated_grantee_signature_is_unauthorized() { + let f = grant_fixture(); + let host = "node.example.com"; + let hosts = [host.to_string()]; + let nonce = [0xF4u8; 32]; + let chal_expiry = 1_700_000_060u64; + let revoked = RevokedGrantSet::new(); + let mut bad_sig = sign_grantee_chal(&f, host, &nonce, chal_expiry); + bad_sig[0] ^= 0x01; + let err = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: f.bech.clone(), + grantee_pk: encode_hex(&f.grantee_pk), + signature: encode_hex(&bad_sig), + }, + &f.op_pk, + &ResolvedScope::unbounded(), + &grant_ctx(&hosts, 1_700_000_000, &revoked), + ) + .expect_err("manipulated grantee signature"); + assert_eq!(err.body.error, "unauthorized"); + } + + #[test] + fn grant_proof_wrong_chan_bind_is_unauthorized() { + // Grantee signs under a different host than the authoritative set. + let f = grant_fixture(); + let signed_host = "other.example.com"; + let served_hosts = ["node.example.com".to_string()]; + let nonce = [0xF5u8; 32]; + let chal_expiry = 1_700_000_060u64; + let revoked = RevokedGrantSet::new(); + let grantee_sig = sign_grantee_chal(&f, signed_host, &nonce, chal_expiry); + let err = verify_grant_proof( + &encode_hex(&nonce), + &chal_expiry.to_string(), + &GrantProofJson { + proof_type: "grant".into(), + grant: f.bech.clone(), + grantee_pk: encode_hex(&f.grantee_pk), + signature: encode_hex(&grantee_sig), + }, + &f.op_pk, + &ResolvedScope::unbounded(), + &grant_ctx(&served_hosts, 1_700_000_000, &revoked), + ) + .expect_err("wrong chan_bind"); + assert_eq!(err.body.error, "unauthorized"); + } +} diff --git a/src/proto_identity.rs b/src/proto_identity.rs new file mode 100644 index 0000000..53655cc --- /dev/null +++ b/src/proto_identity.rs @@ -0,0 +1,145 @@ +//! Identity gate for the carried `kernel.v1` `.proto`. +//! +//! The api repo cannot path-depend on zk-coins/node (separate checkouts). +//! The contract file is therefore carried under `proto/kernel/v1/kernel.proto` +//! (workspace root) and pinned by content hash. +//! +//! ## CI vs local +//! +//! - **CI gate (always):** [`KERNEL_PROTO_SHA256_HEX`] must match the bytes of +//! the carried file. This is the only identity check that can fail in a +//! standalone api checkout (the usual CI shape). +//! - **Local multi-repo worktree (optional):** when a sibling node checkout +//! is present at `../node/proto/kernel/v1/kernel.proto`, the test also +//! requires byte-identity with that file so local stacks catch drift +//! immediately. +//! +//! The sibling comparison is **intentionally not a CI gate**. CI does not +//! check out `zk-coins/node` next to this tree, so a silent `return` on +//! absence would always be green without testing anything. The test below +//! therefore **names** that absence (`eprintln` + early return) and keeps +//! the pin-vs-file assertion as the real, always-on gate. +//! +//! ## PROTO_IDENTITY_CI_BOUNDARY (named follow-up; not fixed here) +//! +//! Pin-vs-file alone does not prove identity with the node contract: a PR can +//! change both the carried proto and the pin together. Closing that gap needs +//! CI to check out node at a fixed ref (or consume an externally versioned +//! proto artefact) and fail closed when the reference is missing — cross-repo +//! CI-checkout follow-up block, not this change. +//! +//! Lives in the **api** package (not `kernel-proto`) so `cargo test -p api` +//! always runs the pin; codegen isolation is a separate concern. + +/// SHA-256 (lowercase hex) of `proto/kernel/v1/kernel.proto` as shipped with +/// this tree. Source: zk-coins/node `proto/kernel/v1/kernel.proto` at the +/// worktree used for this stage (`31bffc90…`). Updating the proto **requires** +/// updating this pin in the same change. +pub const KERNEL_PROTO_SHA256_HEX: &str = + "0aed2f06804c4fe03a0a5d4d3a426332a7f5d6c7b100ef4bf3ad4a40fef5fab9"; + +/// Relative path of the carried contract from the workspace / api crate root. +pub const KERNEL_PROTO_REL: &str = "proto/kernel/v1/kernel.proto"; + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + use std::path::{Path, PathBuf}; + + fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + } + + fn local_proto_path() -> PathBuf { + manifest_dir().join(KERNEL_PROTO_REL) + } + + fn sibling_node_proto_path() -> PathBuf { + manifest_dir() + .join("..") + .join("node") + .join("proto/kernel/v1/kernel.proto") + } + + fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut out = String::with_capacity(64); + for b in digest { + out.push_str(&format!("{b:02x}")); + } + out + } + + /// **CI-relevant gate:** carried file bytes must equal the pin. + #[test] + fn carried_proto_matches_pinned_sha256() { + let path = local_proto_path(); + let bytes = std::fs::read(&path).unwrap_or_else(|e| { + panic!( + "failed to read carried kernel proto at {}: {e}", + path.display() + ) + }); + let got = sha256_hex(&bytes); + assert_eq!( + got, KERNEL_PROTO_SHA256_HEX, + "carried {KERNEL_PROTO_REL} SHA-256 drifted from the pin; \ + if the node contract changed, copy the new file and update \ + KERNEL_PROTO_SHA256_HEX in the same change" + ); + assert!(!bytes.is_empty(), "carried kernel proto must be non-empty"); + let text = std::str::from_utf8(&bytes).expect("proto is UTF-8"); + assert!( + text.contains("package kernel.v1;"), + "carried proto must declare package kernel.v1" + ); + assert!( + text.contains("rpc SubmitTransition"), + "carried proto must include SubmitTransition" + ); + assert!( + text.contains("rpc StreamJob"), + "carried proto must include StreamJob" + ); + } + + /// **Local-only optional check** — not a CI gate. + /// + /// When `../node` is absent (standalone / CI checkout), this test + /// **explicitly skips** after documenting why. It must never be a silent + /// green success that pretends the sibling was compared. The pin test + /// above is the real CI identity gate. + #[test] + fn carried_proto_matches_sibling_node_when_present_local_only() { + let sibling = sibling_node_proto_path(); + if !Path::new(&sibling).is_file() { + // Named skip: absence is expected in CI and standalone api clones. + // Do not treat this as proof that the node contract matches. + eprintln!( + "proto_identity: sibling node proto absent at {} — \ + skipping local multi-repo byte compare (CI gate is pin==file)", + sibling.display() + ); + return; + } + let local = std::fs::read(local_proto_path()).expect("local proto"); + let node = std::fs::read(&sibling).unwrap_or_else(|e| { + panic!( + "failed to read sibling node proto at {}: {e}", + sibling.display() + ) + }); + assert_eq!( + local, + node, + "carried api proto must be byte-identical to sibling node proto at {}", + sibling.display() + ); + assert_eq!( + sha256_hex(&node), + KERNEL_PROTO_SHA256_HEX, + "sibling node proto SHA-256 must equal the pin (node moved without api update)" + ); + } +} diff --git a/src/publish.rs b/src/publish.rs new file mode 100644 index 0000000..fefa55e --- /dev/null +++ b/src/publish.rs @@ -0,0 +1,258 @@ +//! Publisher hand-off REST surface (§7.6): `POST /v1/publish/spendrecord`. +//! +//! | Method | Path | Kernel | +//! |---|---|---| +//! | `POST` | `/v1/publish/spendrecord` | `Publish` | +//! +//! Permissionless — no OwnershipProof, no challenge. A well-formed body is +//! never answered with `401`/`403` for lack of credentials. +//! +//! ## HTTP status discipline (§7.6) +//! +//! | Condition | HTTP | Body | +//! |---|---|---| +//! | Malformed wire body (incl. any v1 fee field set) | **400** | `{ "error": "malformed_request", … }` | +//! | Crypto / policy rejection | **200** | `{ accepted: false, reason: }` | +//! | Accepted | **200** | `{ accepted: true, batch_eta: }` | +//! | Internal failure | **500** | `{ "error": "internal_error", … }` | +//! +//! A publisher rejection is a **successful** RPC result, not a transport or +//! domain error. The REST surface mirrors that: `accepted: false` is still +//! HTTP 200. + +use crate::error::ApiError; +use crate::extract::JsonBody; +use crate::hexutil::decode_hex_exact; +use crate::kernel::kernel_v1::{BlockAnchor, PublishRequest, PublishResult}; +use crate::ownership::parse_u64_decimal; +use crate::state::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Deserialize; +use serde_json::{Map, Value}; + +// --------------------------------------------------------------------------- +// Closed reason set (§7.6 L3079–L3089) +// --------------------------------------------------------------------------- + +/// Normative closed enumeration for `PublishResult.reason` when +/// `accepted == false`. Unknown kernel tokens become `internal_error` — +/// never silently forwarded as an open string. +const PUBLISH_REJECT_REASONS: &[&str] = &[ + "invalid_signature", + "invalid_s2c_opening", + "invalid_fee_coinproof", + "fee_address_mismatch", + "ocr_mismatch", + "fee_too_low", + "unknown_fee_asset", + "policy", + "anchor_stale", +]; + +fn is_closed_reason(reason: &str) -> bool { + PUBLISH_REJECT_REASONS.contains(&reason) +} + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct BlockAnchorJson { + pub block_hash: String, + /// §7.1 decimal-string u32 (same wire form as other request integers). + pub height: String, +} + +#[derive(Debug, Deserialize)] +pub struct PublishSpendRecordBody { + pub public_key: String, + pub r: String, + pub s: String, + pub r_prime: String, + pub block_anchor: BlockAnchorJson, + /// Deferred fee fields — **MUST be absent in v1** (§7.6). Presence → 400. + #[serde(default)] + pub fee_blob_id: Option, + #[serde(default)] + pub fee_blob_locators: Option, + #[serde(default)] + pub fee_epk: Option, +} + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- + +/// `POST /v1/publish/spendrecord` → `Publish`. +pub async fn post_publish_spendrecord( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + // v1 fee fields are fail-closed: any set field is malformed, never ignored. + if body.fee_blob_id.is_some() || body.fee_blob_locators.is_some() || body.fee_epk.is_some() { + return Err(ApiError::malformed( + "fee_blob_id, fee_blob_locators, and fee_epk are deferred and MUST be absent in v1 \ + (§7.6 / §3.8.1); publishing is sponsored", + )); + } + + let public_key = decode_hex32(&body.public_key, "public_key")?; + let r = decode_hex32(&body.r, "r")?; + let s = decode_hex32(&body.s, "s")?; + let r_prime = decode_hex32(&body.r_prime, "r_prime")?; + let block_hash = decode_hex32(&body.block_anchor.block_hash, "block_anchor.block_hash")?; + let height = parse_u32_decimal(&body.block_anchor.height, "block_anchor.height")?; + + let result: PublishResult = state + .kernel + .publish(PublishRequest { + public_key, + r, + s, + r_prime, + // Empty fee fields = fee-less hand-off (v1 only shape). + fee_blob_id: Vec::new(), + fee_epk: Vec::new(), + fee_blob_locators: Vec::new(), + block_anchor: Some(BlockAnchor { block_hash, height }), + }) + .await?; + + let body = publish_result_to_json(&result)?; + Ok((StatusCode::OK, Json(body)).into_response()) +} + +fn decode_hex32(s: &str, field: &str) -> Result, ApiError> { + decode_hex_exact(s, 32).map_err(|e| ApiError::malformed(format!("{field}: {e}"))) +} + +fn parse_u32_decimal(s: &str, field: &str) -> Result { + let v = parse_u64_decimal(s) + .map_err(|e| ApiError::malformed(format!("{field}: {}", e.body.message)))?; + u32::try_from(v).map_err(|_| { + ApiError::malformed(format!( + "{field} must fit in u32 (on-chain height range); got {v}" + )) + }) +} + +/// Map kernel `PublishResult` to the §7.6 JSON shape. +/// +/// Presence invariants (fail-closed): +/// - `accepted == true` ⇔ `batch_eta` present, `reason` absent +/// - `accepted == false` ⇔ `reason` present (closed), `batch_eta` absent +fn publish_result_to_json(result: &PublishResult) -> Result { + let mut obj = Map::new(); + obj.insert("accepted".into(), Value::Bool(result.accepted)); + + if result.accepted { + if result.reason.is_some() { + return Err(ApiError::internal( + "kernel PublishResult.accepted is true but reason is set", + )); + } + let eta = match result.batch_eta { + Some(v) => v, + None => { + return Err(ApiError::internal( + "kernel PublishResult.accepted is true but batch_eta is absent", + )); + } + }; + // Decimal-string u64 — same JSON integer discipline as session_expiry. + obj.insert("batch_eta".into(), Value::String(eta.to_string())); + } else { + if result.batch_eta.is_some() { + return Err(ApiError::internal( + "kernel PublishResult.accepted is false but batch_eta is set", + )); + } + let reason = match &result.reason { + Some(r) if !r.is_empty() => r.as_str(), + Some(_) => { + return Err(ApiError::internal( + "kernel PublishResult.accepted is false but reason is empty", + )); + } + None => { + return Err(ApiError::internal( + "kernel PublishResult.accepted is false but reason is absent", + )); + } + }; + if !is_closed_reason(reason) { + return Err(ApiError::internal(format!( + "kernel PublishResult.reason {reason:?} is not in the §7.6 closed set" + ))); + } + obj.insert("reason".into(), Value::String(reason.to_string())); + } + + Ok(Value::Object(obj)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn closed_reason_set_matches_spec_count() { + assert_eq!(PUBLISH_REJECT_REASONS.len(), 9); + assert!(is_closed_reason("policy")); + assert!(is_closed_reason("invalid_signature")); + assert!(!is_closed_reason("not_a_reason")); + assert!(!is_closed_reason("")); + } + + #[test] + fn accepted_result_json() { + let r = PublishResult { + accepted: true, + reason: None, + batch_eta: Some(30), + }; + let json = publish_result_to_json(&r).unwrap(); + assert_eq!(json["accepted"], true); + assert_eq!(json["batch_eta"], "30"); + assert!(json.get("reason").is_none()); + } + + #[test] + fn rejected_result_json() { + let r = PublishResult { + accepted: false, + reason: Some("policy".into()), + batch_eta: None, + }; + let json = publish_result_to_json(&r).unwrap(); + assert_eq!(json["accepted"], false); + assert_eq!(json["reason"], "policy"); + assert!(json.get("batch_eta").is_none()); + } + + #[test] + fn accepted_with_reason_is_internal() { + let r = PublishResult { + accepted: true, + reason: Some("policy".into()), + batch_eta: Some(1), + }; + let err = publish_result_to_json(&r).unwrap_err(); + assert_eq!(err.body.error, "internal_error"); + } + + #[test] + fn rejected_with_unknown_reason_is_internal() { + let r = PublishResult { + accepted: false, + reason: Some("invented".into()), + batch_eta: None, + }; + let err = publish_result_to_json(&r).unwrap_err(); + assert_eq!(err.body.error, "internal_error"); + } +} diff --git a/src/pull.rs b/src/pull.rs new file mode 100644 index 0000000..999dcba --- /dev/null +++ b/src/pull.rs @@ -0,0 +1,807 @@ +//! Capability-gated pull REST surface (§7.5 L3039–L3044). +//! +//! | Method | Path | Kernel | +//! |---|---|---| +//! | `POST` | `/v1/pull/challenge` | `OpenPullChallenge` action=`pull` | +//! | `POST` | `/v1/pull` | `Pull` (after OwnershipProof **or** GrantProof) | +//! | `GET` | `/v1/record/` | `GetRecord` | +//! | `GET` | `/v1/proof/` | `GetCoinProof` | +//! | `GET` | `/v1/account/state` | `GetAccountState` (ownership session only) | +//! | `GET` | `/v1/receipts/stream` | `SubscribeReceipts` (ownership **or** grant session) | +//! +//! The API holds **no** session store: the bearer token is forwarded to the +//! kernel. Session authority is taken solely from the verified proof kind and +//! sent as interim metadata `x-zkcoins-session-authority` (never defaulted). +//! The **resolved (intersected) scope** is computed here and sent on `Pull`; +//! the kernel records it into the session and never widens it. +//! +//! `GET /v1/receipts/stream` admits **any** still-valid ownership **or** grant +//! pull session (§7.5 L2953) — unlike `GET /v1/account/state`, which is +//! ownership-only. Subject and resolved scope come from server-side session +//! state; the request carries no `subject` field. + +use crate::error::ApiError; +use crate::extract::JsonBody; +use crate::hexutil::{decode_hex_exact, encode_hex}; +use crate::kernel::kernel_v1::{ + AccountStateRequest, AccountStateResult, CoinProofBlob, CoinProofRequest, PullChallengeRequest, + PullRequest, PullResult as ProtoPullResult, Receipt, RecordBlob, RecordRef, RecordRequest, + Scope, SubscribeReceiptsRequest, +}; +use crate::ownership::{ + chan_bind_for_host, decode_zk_address, parse_u64_decimal, validate_resolved_scope, + verify_grant_proof, verify_pull_ownership_proof, GrantProofJson, GrantVerificationContext, + OwnershipProofJson, ResolvedScope, SessionAuthority, PULL_CHALLENGE_DOMAIN, + SCOPE_NOT_AFTER_UNBOUNDED, +}; +use crate::state::AppState; +use axum::extract::{Path, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use futures_util::stream::Stream; +use futures_util::StreamExt; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::convert::Infallible; +use std::time::{SystemTime, UNIX_EPOCH}; + +// --------------------------------------------------------------------------- +// Wire types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub struct PullChallengeBody { + pub subject: String, + #[serde(default)] + pub scope: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PullScopeJson { + /// Either the string `"*"` or an array of hex32 asset ids. + pub asset_ids: Value, + #[serde(default)] + pub not_before: Option, + #[serde(default)] + pub not_after: Option, +} + +/// Redeem body: top-level `{ nonce, expiry, proof, scope? }`. +/// +/// Redeem-body `expiry` is normative (bound into signed `chal`). Optional +/// `scope` re-echoes the requested scope so a **stateless** API edge can +/// compute `requested ∩ capability` without a challenge store (§5.1). Omitted +/// scope normalises to the unbounded sentinel pair before intersection. +#[derive(Debug, Deserialize)] +pub struct PullBody { + pub nonce: String, + /// Challenge expiry echoed from issuance (bound into signed `chal`; not + /// trusted as a clock source — a forged value fails BIP-340). + pub expiry: String, + pub proof: PullProofJson, + /// Requested scope re-echo (same shape as challenge). Omitted ⇒ unbounded. + #[serde(default)] + pub scope: Option, +} + +/// Closed proof discriminator for `POST /v1/pull`. +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum PullProofJson { + #[serde(rename = "ownership")] + Ownership { + subject: String, + public_key: String, + nk_commit: String, + signature: String, + }, + #[serde(rename = "grant")] + Grant { + grant: String, + grantee_pk: String, + signature: String, + }, +} + +// --------------------------------------------------------------------------- +// Scope normalisation (§5.1 / §7.5) +// --------------------------------------------------------------------------- + +/// Normalise REST scope to the single unbounded-sentinel pair **before** +/// the kernel RPC and any scope intersection (§5.1 L1918). +fn normalise_scope(scope: &PullScopeJson) -> Result { + let (all_assets, asset_ids) = match &scope.asset_ids { + Value::String(s) if s == "*" => (true, Vec::new()), + Value::String(s) => { + return Err(ApiError::malformed(format!( + "scope.asset_ids string must be \"*\", got {s:?}" + ))); + } + Value::Array(arr) => { + let mut ids = Vec::with_capacity(arr.len()); + for (i, v) in arr.iter().enumerate() { + let hex = v.as_str().ok_or_else(|| { + ApiError::malformed(format!("scope.asset_ids[{i}] must be a hex string")) + })?; + let raw = decode_hex_exact(hex, 32) + .map_err(|e| ApiError::malformed(format!("scope.asset_ids[{i}]: {e}")))?; + let mut a = [0u8; 32]; + a.copy_from_slice(&raw); + ids.push(a); + } + if ids.is_empty() { + return Err(ApiError::malformed( + "scope.asset_ids list must be non-empty when not \"*\"", + )); + } + (false, ids) + } + other => { + return Err(ApiError::malformed(format!( + "scope.asset_ids must be \"*\" or an array of hex32, got {other}" + ))); + } + }; + + let not_before = match &scope.not_before { + None => 0u64, + Some(s) => parse_u64_decimal(s) + .map_err(|e| ApiError::malformed(format!("scope.not_before: {}", e.body.message)))?, + }; + let not_after = match &scope.not_after { + None => SCOPE_NOT_AFTER_UNBOUNDED, + Some(s) => parse_u64_decimal(s) + .map_err(|e| ApiError::malformed(format!("scope.not_after: {}", e.body.message)))?, + }; + + let resolved = ResolvedScope { + all_assets, + asset_ids, + not_before, + not_after, + }; + // Canonical form before any Challenge/Redeem kernel RPC: strictly + // ascending unique asset ids; non-empty time interval. + validate_resolved_scope(&resolved)?; + Ok(resolved) +} + +fn scope_to_proto(scope: &ResolvedScope) -> Scope { + Scope { + asset_ids: scope.asset_ids.iter().map(|a| a.to_vec()).collect(), + all_assets: scope.all_assets, + not_before: scope.not_before, + not_after: scope.not_after, + } +} + +fn unix_now() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .map_err(|_| ApiError::internal("system clock is before Unix epoch")) +} + +// --------------------------------------------------------------------------- +// Closed wire vocabularies (§7.5 PullResult) +// --------------------------------------------------------------------------- + +fn map_record_type(raw: &str) -> Result<&'static str, ApiError> { + match raw { + "coinproof" => Ok("coinproof"), + "self_delivery" => Ok("self_delivery"), + other => Err(ApiError::internal(format!( + "kernel RecordRef.record_type is outside the closed set \ + (\"coinproof\"|\"self_delivery\"): {other:?}" + ))), + } +} + +/// Map optional `transition_kind`. Empty string means absent (coinproof). +/// Required non-empty for `self_delivery`. +fn map_transition_kind(raw: &str, record_type: &str) -> Result, ApiError> { + if raw.is_empty() { + if record_type == "self_delivery" { + return Err(ApiError::internal( + "kernel RecordRef.transition_kind is required for record_type=self_delivery", + )); + } + return Ok(None); + } + match raw { + "mint" => Ok(Some("mint")), + "send" => Ok(Some("send")), + "receive" => Ok(Some("receive")), + other => Err(ApiError::internal(format!( + "kernel RecordRef.transition_kind is outside the closed set \ + (\"mint\"|\"send\"|\"receive\"): {other:?}" + ))), + } +} + +fn record_ref_to_json(r: &RecordRef) -> Result { + if r.record_id.len() != 32 { + return Err(ApiError::internal(format!( + "kernel RecordRef.record_id must be 32 bytes, got {}", + r.record_id.len() + ))); + } + if r.blob_id.len() != 32 { + return Err(ApiError::internal(format!( + "kernel RecordRef.blob_id must be 32 bytes, got {}", + r.blob_id.len() + ))); + } + let record_type = map_record_type(&r.record_type)?; + let transition_kind = map_transition_kind(&r.transition_kind, record_type)?; + + let mut obj = serde_json::Map::new(); + obj.insert("record_id".into(), Value::String(encode_hex(&r.record_id))); + obj.insert("record_type".into(), Value::String(record_type.to_string())); + if let Some(kind) = transition_kind { + obj.insert("transition_kind".into(), Value::String(kind.to_string())); + } + obj.insert("blob_id".into(), Value::String(encode_hex(&r.blob_id))); + obj.insert( + "occurred_at".into(), + Value::String(r.occurred_at.to_string()), + ); + Ok(Value::Object(obj)) +} + +// --------------------------------------------------------------------------- +// Session / bearer helpers +// --------------------------------------------------------------------------- + +/// Extract `Authorization: Bearer `. +/// +/// Missing or malformed → `401 unauthorized` (§7.5: never collapse into +/// `session_expired` / 410). +fn bearer_token(headers: &HeaderMap) -> Result { + let Some(value) = headers.get(header::AUTHORIZATION) else { + return Err(ApiError::unauthorized( + "missing Authorization bearer token for pull session", + )); + }; + let s = value + .to_str() + .map_err(|_| ApiError::unauthorized("Authorization header is not valid UTF-8"))?; + let Some(token) = s.strip_prefix("Bearer ") else { + return Err(ApiError::unauthorized( + "Authorization must be \"Bearer \"", + )); + }; + if token.is_empty() { + return Err(ApiError::unauthorized("bearer token is empty")); + } + // Whitespace or control characters are not a node-issued credential shape. + if token.bytes().any(|b| b.is_ascii_whitespace() || b < 0x20) { + return Err(ApiError::unauthorized( + "bearer token is malformed (whitespace or control bytes)", + )); + } + Ok(token.to_string()) +} + +/// Authoritative `chan_bind` for session-bound follow-ups. +/// +/// # Single host (this stage) +/// +/// Exactly one configured public host is required here. Proof verification on +/// `POST /v1/pull` already accepts **any** of the configured hosts (try each +/// `chan_bind` until BIP-340 verifies — §5.1). Session follow-ups are different: +/// the session record stores **one** `chan_bind` from the accepting proof, and +/// the API must recompute that same value for the current connection so the +/// kernel can equality-check it. +/// +/// # Why multi-host is refused (not silently left open) +/// +/// Spec §5.1 forbids deriving `host` from attacker-influenceable request +/// metadata such as a forwarded `Host` header. With several authoritative +/// names the API therefore cannot know which host the client dialed on this +/// TCP/TLS connection without a **trusted** side channel (e.g. TLS SNI as +/// observed by a co-located terminator, or a single front-end name). Until +/// that path exists, multi-host session re-bind fails closed with 500 rather +/// than guessing — guessing would either reject legitimate clients or accept +/// a captured token against the wrong name. +/// +/// What would close the GAP: a trusted connection-identity input (SNI / +/// local socket metadata) that selects exactly one entry of +/// `ZKCOINS_PUBLIC_HOST` per request, still never the client `Host` header. +fn session_chan_bind(public_hosts: &[String]) -> Result<[u8; 32], ApiError> { + match public_hosts { + [] => Err(ApiError::internal( + "no authoritative public hosts configured for chan_bind (ZKCOINS_PUBLIC_HOST)", + )), + [only] => Ok(chan_bind_for_host(only)), + _ => Err(ApiError::internal( + "session channel binding requires exactly one ZKCOINS_PUBLIC_HOST: \ + multi-host re-bind needs a trusted SNI/connection-identity path \ + (not the client Host header; §5.1). Proof verification already \ + accepts any configured host; only follow-up session routes are restricted", + )), + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `POST /v1/pull/challenge` → OpenPullChallenge(action=pull). +pub async fn post_pull_challenge( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + if body.subject.is_empty() { + return Err(ApiError::malformed("subject is required")); + } + let _ = decode_zk_address(&body.subject)?; + + let requested_scope = match &body.scope { + None => None, + Some(s) => Some(scope_to_proto(&normalise_scope(s)?)), + }; + + let challenge = state + .kernel + .open_pull_challenge(PullChallengeRequest { + subject: body.subject, + requested_scope, + action: "pull".to_string(), + }) + .await?; + + if challenge.nonce.len() != 32 { + return Err(ApiError::internal(format!( + "kernel Challenge.nonce must be 32 bytes, got {}", + challenge.nonce.len() + ))); + } + if challenge.domain != PULL_CHALLENGE_DOMAIN { + return Err(ApiError::internal(format!( + "kernel Challenge.domain must be {PULL_CHALLENGE_DOMAIN:?}, got {:?}", + challenge.domain + ))); + } + + let body = json!({ + "nonce": encode_hex(&challenge.nonce), + "expiry": challenge.expiry.to_string(), + "domain": PULL_CHALLENGE_DOMAIN, + }); + Ok((StatusCode::OK, Json(body)).into_response()) +} + +/// `POST /v1/pull` → verify proof, then `Pull`. +/// +/// OwnershipProof and GrantProof are verified **pure** (no kernel) so a bad +/// signature cannot consume the single-use nonce. The resolved scope passed +/// to the kernel is exactly what the capability authorises after intersection +/// with the requested scope — never widened, never defaulted to unbounded +/// under a scoped grant. +pub async fn post_pull( + State(state): State, + JsonBody(body): JsonBody, +) -> Result { + // Requested scope: re-echo on redeem, or unbounded sentinels when omitted. + let requested_scope = match &body.scope { + None => ResolvedScope::unbounded(), + Some(s) => normalise_scope(s)?, + }; + + // ---- pure validation + capability gate (no kernel) ---- + let (subject_bech32, nonce, chan_bind, resolved, authority) = match body.proof { + PullProofJson::Ownership { + subject, + public_key, + nk_commit, + signature, + } => { + let proof = OwnershipProofJson { + proof_type: "ownership".into(), + subject: subject.clone(), + public_key, + nk_commit, + signature, + }; + let v = verify_pull_ownership_proof( + &subject, + &body.nonce, + &body.expiry, + &proof, + state.public_hosts.as_slice(), + )?; + // Ownership authorises the full account: resolved = requested + // (requester may narrow; omitted/`*` ⇒ whole account). §5.1(a). + ( + v.subject_bech32, + v.nonce, + v.chan_bind, + requested_scope, + SessionAuthority::Ownership, + ) + } + PullProofJson::Grant { + grant, + grantee_pk, + signature, + } => { + let proof = GrantProofJson { + proof_type: "grant".into(), + grant: grant.clone(), + grantee_pk, + signature, + }; + // Decode first so we know which subject's published op to load. + let decoded = crate::ownership::decode_view_grant(&grant)?; + let op_pubkey = match state.subject_ops.get(&decoded.subject) { + Some(pk) => pk, + None => { + return Err(ApiError::unauthorized( + "GrantProof rejected: subject's published op_pubkey is not available \ + (Nostr kind-30420 profile resolution with §4.3 address binding is \ + not wired; subject_ops directory has no entry). Half-checked grants \ + are forbidden (§5.1(b) step 1)", + )); + } + }; + let now = unix_now()?; + let v = verify_grant_proof( + &body.nonce, + &body.expiry, + &proof, + &op_pubkey, + &requested_scope, + &GrantVerificationContext { + public_hosts: state.public_hosts.as_slice(), + now, + revoked: state.revoked_grants.as_ref(), + }, + )?; + // Fail-closed belt: a grant session must never carry a fully + // unbounded scope when the grant itself was scoped. + if v.resolved_scope.is_fully_unbounded() && !v.grant_scope.is_fully_unbounded() { + return Err(ApiError::internal( + "grant resolved_scope is fully unbounded while grant.scope is not — refuse", + )); + } + ( + v.subject_bech32, + v.nonce, + v.chan_bind, + v.resolved_scope, + SessionAuthority::Grant, + ) + } + }; + + // ---- only now: kernel (nonce consumption lives here) ---- + let result: ProtoPullResult = state + .kernel + .pull( + PullRequest { + nonce: nonce.to_vec(), + subject: subject_bech32, + resolved_scope: Some(scope_to_proto(&resolved)), + chan_bind: chan_bind.to_vec(), + }, + authority, + ) + .await?; + + if result.session.is_empty() { + return Err(ApiError::internal( + "kernel PullResult.session is empty on Pull success", + )); + } + + let mut records = Vec::with_capacity(result.records.len()); + for r in &result.records { + records.push(record_ref_to_json(r)?); + } + + let body = json!({ + "records": records, + "session": result.session, + "session_expiry": result.session_expiry.to_string(), + }); + Ok((StatusCode::OK, Json(body)).into_response()) +} + +/// `GET /v1/record/` → canonical binary (§7.5 L3041). +/// +/// Content-Type: `application/octet-stream` (same binary transport class as +/// §7.4 Blossom; §7.5 names the body as canonical §7.1 bytes, not JSON). +pub async fn get_record( + State(state): State, + Path(record_id_hex): Path, + headers: HeaderMap, +) -> Result { + let session = bearer_token(&headers)?; + let chan_bind = session_chan_bind(state.public_hosts.as_slice())?; + let record_id = decode_hex_exact(&record_id_hex, 32) + .map_err(|e| ApiError::malformed(format!("record_id: {e}")))?; + + let blob: RecordBlob = state + .kernel + .get_record(RecordRequest { + record_id, + session, + chan_bind: chan_bind.to_vec(), + }) + .await?; + + // Validate closed type metadata from the kernel even though the REST + // response is raw bytes only — an unknown type must not be released. + let record_type = map_record_type(&blob.record_type)?; + let _ = map_transition_kind(&blob.transition_kind, record_type)?; + + if blob.canonical.is_empty() { + return Err(ApiError::internal( + "kernel RecordBlob.canonical is empty on GetRecord success", + )); + } + + Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/octet-stream")], + blob.canonical, + ) + .into_response()) +} + +/// `GET /v1/proof/` → canonical CoinProof bytes (§7.5 L3042). +pub async fn get_proof( + State(state): State, + Path(coin_id_hex): Path, + headers: HeaderMap, +) -> Result { + let session = bearer_token(&headers)?; + let chan_bind = session_chan_bind(state.public_hosts.as_slice())?; + let coin_id = decode_hex_exact(&coin_id_hex, 32) + .map_err(|e| ApiError::malformed(format!("coin_id: {e}")))?; + + let blob: CoinProofBlob = state + .kernel + .get_coin_proof(CoinProofRequest { + coin_id, + session, + chan_bind: chan_bind.to_vec(), + }) + .await?; + + if blob.canonical.is_empty() { + return Err(ApiError::internal( + "kernel CoinProofBlob.canonical is empty on GetCoinProof success", + )); + } + + Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/octet-stream")], + blob.canonical, + ) + .into_response()) +} + +/// `GET /v1/account/state` → ownership-only account head (§7.5 L3043). +/// +/// Consistency of `send_counter` / `current_pubkey` with the bytes inside +/// `account_state` is a **kernel** guarantee (proto comments / §7.8); the +/// API does not re-parse or recompute those fields. +pub async fn get_account_state( + State(state): State, + headers: HeaderMap, +) -> Result { + let session = bearer_token(&headers)?; + let chan_bind = session_chan_bind(state.public_hosts.as_slice())?; + + let view: AccountStateResult = state + .kernel + .get_account_state(AccountStateRequest { + session, + chan_bind: chan_bind.to_vec(), + }) + .await?; + + if view.account_state.is_empty() { + return Err(ApiError::internal( + "kernel AccountStateResult.account_state is empty on success", + )); + } + if view.state_head.len() != 32 { + return Err(ApiError::internal(format!( + "kernel AccountStateResult.state_head must be 32 bytes, got {}", + view.state_head.len() + ))); + } + if view.current_pubkey.len() != 32 { + return Err(ApiError::internal(format!( + "kernel AccountStateResult.current_pubkey must be 32 bytes, got {}", + view.current_pubkey.len() + ))); + } + // Optional head_record_id: empty = absent; otherwise exactly 32. + if !view.head_record_id.is_empty() && view.head_record_id.len() != 32 { + return Err(ApiError::internal(format!( + "kernel AccountStateResult.head_record_id must be empty or 32 bytes, got {}", + view.head_record_id.len() + ))); + } + // last_nullifier: both present (32B each) or both empty. + let last_nullifier = match ( + view.last_nullifier_pk.is_empty(), + view.last_nullifier_r.is_empty(), + ) { + (true, true) => None, + (false, false) => { + if view.last_nullifier_pk.len() != 32 || view.last_nullifier_r.len() != 32 { + return Err(ApiError::internal(format!( + "kernel last_nullifier fields must be 32 bytes each when present \ + (pk={}, r={})", + view.last_nullifier_pk.len(), + view.last_nullifier_r.len() + ))); + } + Some(json!({ + "pubkey": encode_hex(&view.last_nullifier_pk), + "r": encode_hex(&view.last_nullifier_r), + })) + } + _ => { + return Err(ApiError::internal( + "kernel last_nullifier_pk and last_nullifier_r must both be present or both empty", + )); + } + }; + + let mut body = serde_json::Map::new(); + body.insert( + "account_state".into(), + Value::String(encode_hex(&view.account_state)), + ); + body.insert( + "state_head".into(), + Value::String(encode_hex(&view.state_head)), + ); + if !view.head_record_id.is_empty() { + body.insert( + "head_record_id".into(), + Value::String(encode_hex(&view.head_record_id)), + ); + } + body.insert( + "send_counter".into(), + Value::Number(view.send_counter.into()), + ); + body.insert( + "current_pubkey".into(), + Value::String(encode_hex(&view.current_pubkey)), + ); + if let Some(nf) = last_nullifier { + body.insert("last_nullifier".into(), nf); + } + + Ok((StatusCode::OK, Json(Value::Object(body))).into_response()) +} + +/// `GET /v1/receipts/stream` → `SubscribeReceipts` as SSE (§7.5 L2953–L2955). +/// +/// Auth split (fail-closed, same as `GET /v1/proof/`): +/// - missing / malformed bearer → `401 unauthorized` (API edge, no kernel) +/// - unknown / expired / `chan_bind`-mismatch session → `410 session_expired` +/// (kernel `ErrorInfo`, before the SSE upgrade) +/// +/// Ownership **or** grant sessions are both admissible. Subject and resolved +/// scope are **not** taken from the request — the kernel looks them up from +/// the session record. No recovery buffer, no sequence numbers: reconnect and +/// catch-up via ordinary pull are client-side (§4.9). +/// +/// Pattern matches `GET /v1/jobs//stream`: handshake errors return as +/// HTTP status + JSON; only a successful kernel stream becomes +/// `text/event-stream`. Dropping the SSE consumer drops the gRPC stream and +/// ends the subscription. +pub async fn stream_receipts( + State(state): State, + headers: HeaderMap, +) -> Result> + Send + 'static>, ApiError> { + let session = bearer_token(&headers)?; + let chan_bind = session_chan_bind(state.public_hosts.as_slice())?; + + // Await the kernel stream handshake first. On `Err`, axum maps `ApiError` + // to a normal HTTP response (status + JSON body) and never enters SSE. + let stream = state + .kernel + .subscribe_receipts(SubscribeReceiptsRequest { + session, + chan_bind: chan_bind.to_vec(), + }) + .await?; + + let sse_stream = receipt_event_sse_stream(stream); + Ok(Sse::new(sse_stream).keep_alive(KeepAlive::default())) +} + +// --------------------------------------------------------------------------- +// Receipts SSE +// --------------------------------------------------------------------------- + +fn receipt_event_sse_stream(stream: S) -> impl Stream> + Send +where + S: Stream> + Send + 'static, +{ + // Map each kernel receipt to one SSE frame. On stream break, emit a single + // recognizable `error` frame then end — never hang open with silence. + // Clean end (`None`) closes without a terminal frame (open-ended push). + // + // Dropping this unfold (client disconnect) drops `stream`, which drops the + // tonic gRPC subscription — same cleanup pattern as the job stream. + futures_util::stream::unfold((Box::pin(stream), false), |(mut stream, done)| async move { + if done { + return None; + } + match stream.next().await { + None => None, + Some(Ok(receipt)) => match receipt_to_sse(&receipt) { + Ok(frame) => Some((Ok(frame), (stream, false))), + Err(api_err) => { + let frame = receipt_stream_break_event(&api_err); + Some((Ok(frame), (stream, true))) + } + }, + Some(Err(api_err)) => { + let frame = receipt_stream_break_event(&api_err); + Some((Ok(frame), (stream, true))) + } + } + }) +} + +fn receipt_stream_break_event(err: &ApiError) -> Event { + let data = json!({ + "error": err.body.error, + "message": err.body.message, + }); + Event::default().event("error").data(data.to_string()) +} + +fn receipt_to_sse(r: &Receipt) -> Result { + let data = receipt_to_json(r)?; + Ok(Event::default().event("receipt").data(data.to_string())) +} + +/// §7.8 `Receipt` as public JSON: hex32 digests, decimal strings for +/// `amount` / `credited_at` (§7.1). +fn receipt_to_json(r: &Receipt) -> Result { + if r.coin_id.len() != 32 { + return Err(ApiError::internal(format!( + "kernel Receipt.coin_id must be 32 bytes, got {}", + r.coin_id.len() + ))); + } + if r.asset_id.len() != 32 { + return Err(ApiError::internal(format!( + "kernel Receipt.asset_id must be 32 bytes, got {}", + r.asset_id.len() + ))); + } + if r.amount.is_empty() { + return Err(ApiError::internal( + "kernel Receipt.amount is empty on SubscribeReceipts success", + )); + } + if r.state.is_empty() { + return Err(ApiError::internal( + "kernel Receipt.state is empty on SubscribeReceipts success", + )); + } + Ok(json!({ + "coin_id": encode_hex(&r.coin_id), + "asset_id": encode_hex(&r.asset_id), + "amount": r.amount, + "state": r.state, + "credited_at": r.credited_at.to_string(), + })) +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..5c94033 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,6778 @@ +//! HTTP routes that this process actually serves. +//! +//! Route registration and the `GET /` discovery document share one source: +//! [`ServedSurface`]. The closed §7.5 inventory ([`CLOSED_ENDPOINT_KEYS`]) is +//! the full key catalogue. **Active** surfaces (from `Config::features` and +//! Blossom store configuration) get real handlers and appear on `GET /`. +//! **Known but inactive** feature-gated surfaces still register a stub that +//! answers `404 feature_disabled` with the §7.5 JSON body — they are omitted +//! from discovery (§7.5 / §6.1 fail-closed gating). **Unconfigured** Blossom +//! (no store) is left unregistered (bare axum 404), not a feature stub. Paths +//! outside the inventory remain a bare axum 404. +//! +//! Inventory paths are the **advertised** §7.5 form (`` placeholders). +//! Axum registration uses a derived **matcher** form (`:name`); see +//! [`advertised_path_to_axum_matcher`]. + +use crate::attest; +use crate::blossom; +use crate::bootstrap; +use crate::chain; +use crate::config::{Config, Feature}; +use crate::error::ApiError; +use crate::grants; +use crate::info; +use crate::jobs; +use crate::kernel::KernelHandle; +use crate::publish; +use crate::pull; +use crate::state::AppState; +use axum::extract::{DefaultBodyLimit, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, head, post, put}; +use axum::{Json, Router}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; + +/// Boot-time failure opening configured resources (e.g. Blossom store root). +/// +/// Distinct from per-request [`ApiError`]: `main` prints this and exits +/// without panicking, same as other start errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartupError { + pub message: String, +} + +impl fmt::Display for StartupError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for StartupError {} + +/// Closed `endpoints` key set from specification §7.5 (`GET /` row). +/// +/// Full inventory of the 28 logical names a conforming producer may emit +/// (data permanence: no `blossom_delete`). Order matches the closed §7.5 +/// listing. This constant is the reference for surfaces not yet built; it is +/// **not** what `GET /` returns. +/// +/// Path parameters use the §7.5 advertised form `` (one path segment). +/// That string is what `GET /` emits. Axum 0.7 / matchit 0.7 do **not** treat +/// `` (or `{name}`) as a parameter — only `:name` is dynamic — so +/// registration rewrites via [`advertised_path_to_axum_matcher`]. Discovery +/// never uses the matcher form; clients see Spec-Schreibweise only. +/// +/// A conforming producer emits exactly the closed keys **for the surfaces this +/// deployment exposes** and MUST omit keys for unadvertised optional roles. +/// Advertisement is derived from [`ServedSurface`], intersected with this +/// inventory via [`closed_path`]. +pub const CLOSED_ENDPOINT_KEYS: &[(&str, &str)] = &[ + ("health", "/health"), + ("health_ready", "/health/ready"), + ("info", "/v1/info"), + ("chain_accumulator", "/v1/chain/accumulator"), + ("chain_inscriptions", "/v1/chain/inscriptions"), + ("chain_nullifier", "/v1/chain/nullifier/"), + ("tx", "/v1/tx"), + ("jobs", "/v1/jobs/"), + ("jobs_stream", "/v1/jobs//stream"), + ("jobs_sign", "/v1/jobs//sign"), + ("jobs_cancel", "/v1/jobs//cancel"), + ("attest_balance_challenge", "/v1/attest/balance/challenge"), + ("attest_balance", "/v1/attest/balance"), + ("grants_challenge", "/v1/grants/challenge"), + ("grants", "/v1/grants"), + ("pull_challenge", "/v1/pull/challenge"), + ("pull", "/v1/pull"), + ("record", "/v1/record/"), + ("proof", "/v1/proof/"), + ("account_state", "/v1/account/state"), + ("receipts_stream", "/v1/receipts/stream"), + ("publish_spendrecord", "/v1/publish/spendrecord"), + ("bootstrap_challenge", "/v1/bootstrap/challenge"), + ("bootstrap_entrust", "/v1/bootstrap/entrust"), + ("bootstrap_revoke", "/v1/bootstrap/revoke"), + ("blossom_get", "/blossom/"), + ("blossom_head", "/blossom/"), + ("blossom_upload", "/blossom/upload"), +]; + +/// Surfaces this process actually registers (and therefore advertises on `GET /`). +/// +/// **Single source of truth** for both the axum router and the discovery +/// document. Adding a surface requires a new enum variant; the compiler then +/// forces every `match` (discovery key, handler registration) to be updated. +/// A key with no handler therefore fails at compile time. A route that is not +/// wired through this enum cannot appear in discovery — registration and +/// advertisement stay in lockstep. +/// +/// `GET /` itself is the discovery document and has **no** closed key in +/// §7.5; it is registered beside this set, never as a member of it. +/// +/// ## Feature gating (§6.1 / §7.5) +/// +/// Which surfaces are active follows `Config::features` and Blossom store +/// configuration — never a hard-coded always-on set of role-bound routes. +/// A request against a disabled feature is answered `404 feature_disabled` +/// (JSON machine code); `GET /` omits the corresponding keys. Mapping (from +/// §6.1 feature table + the §7.5 inventory, mirrored in `docs/rest-surface.md`): +/// +/// | Surfaces | Gate | +/// |---|---| +/// | `health`, `health_ready`, `info` | always (API process) | +/// | `chain_*` | `explorer` | +/// | `tx`, `jobs*`, `attest_*`, `grants_*`, `pull*`, `record`, `proof`, `account_state`, `receipts_stream`, `bootstrap_*` | `wallet` | +/// | `publish_spendrecord` | `publisher` | +/// | `blossom_get` / `blossom_head` / `blossom_upload` | `ZKCOINS_BLOSSOM_STORE` **and** (`wallet` **or** `explorer`) | +/// +/// `lightning_bridge` / `mail_bridge` open no §7.5 inventory paths (extension +/// docs only) and therefore add no variants here. +/// +/// Inventory keys remain in [`CLOSED_ENDPOINT_KEYS`]; advertisement is exactly +/// the active set derived by [`ServedSurface::active`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ServedSurface { + Health, + HealthReady, + Info, + ChainAccumulator, + ChainInscriptions, + ChainNullifier, + Tx, + Jobs, + JobsStream, + JobsSign, + JobsCancel, + AttestBalanceChallenge, + AttestBalance, + GrantsChallenge, + Grants, + PullChallenge, + Pull, + Record, + Proof, + AccountState, + ReceiptsStream, + PublishSpendrecord, + BootstrapChallenge, + BootstrapEntrust, + BootstrapRevoke, + BlossomGet, + BlossomHead, + BlossomUpload, +} + +impl ServedSurface { + /// Full inventory of surfaces this binary knows how to register. + /// + /// Activation is decided per entry by [`ServedSurface::is_active`]; this + /// list is **not** what `GET /` returns. + const ALL: &[ServedSurface] = &[ + ServedSurface::Health, + ServedSurface::HealthReady, + ServedSurface::Info, + ServedSurface::ChainAccumulator, + ServedSurface::ChainInscriptions, + ServedSurface::ChainNullifier, + ServedSurface::Tx, + ServedSurface::Jobs, + ServedSurface::JobsStream, + ServedSurface::JobsSign, + ServedSurface::JobsCancel, + ServedSurface::AttestBalanceChallenge, + ServedSurface::AttestBalance, + ServedSurface::GrantsChallenge, + ServedSurface::Grants, + ServedSurface::PullChallenge, + ServedSurface::Pull, + ServedSurface::Record, + ServedSurface::Proof, + ServedSurface::AccountState, + ServedSurface::ReceiptsStream, + ServedSurface::PublishSpendrecord, + ServedSurface::BootstrapChallenge, + ServedSurface::BootstrapEntrust, + ServedSurface::BootstrapRevoke, + ServedSurface::BlossomGet, + ServedSurface::BlossomHead, + ServedSurface::BlossomUpload, + ]; + + /// Whether this surface is a Blossom inventory key. + fn is_blossom(self) -> bool { + matches!( + self, + ServedSurface::BlossomGet | ServedSurface::BlossomHead | ServedSurface::BlossomUpload + ) + } + + /// Whether this surface is **active** (real handler + discovery key) for + /// the given feature set and Blossom store configuration. Inactive + /// feature-gated inventory surfaces still mount a `feature_disabled` + /// stub; unconfigured Blossom is left unregistered (see + /// [`build_router`]). + fn is_active(self, features: &BTreeSet, blossom_configured: bool) -> bool { + match self { + // Always-on API process surface (§7.5 L2874–L2877; rest-surface #1–#4). + ServedSurface::Health | ServedSurface::HealthReady | ServedSurface::Info => true, + + // `explorer` — public chain projection (§6.1 L2338; rest-surface #5–#7). + ServedSurface::ChainAccumulator + | ServedSurface::ChainInscriptions + | ServedSurface::ChainNullifier => features.contains(&Feature::Explorer), + + // `wallet` — proving, submission, pull, attest, grants, bootstrap + // (§6.1 L2337; rest-surface #8–#22, #24–#26). + ServedSurface::Tx + | ServedSurface::Jobs + | ServedSurface::JobsStream + | ServedSurface::JobsSign + | ServedSurface::JobsCancel + | ServedSurface::AttestBalanceChallenge + | ServedSurface::AttestBalance + | ServedSurface::GrantsChallenge + | ServedSurface::Grants + | ServedSurface::PullChallenge + | ServedSurface::Pull + | ServedSurface::Record + | ServedSurface::Proof + | ServedSurface::AccountState + | ServedSurface::ReceiptsStream + | ServedSurface::BootstrapChallenge + | ServedSurface::BootstrapEntrust + | ServedSurface::BootstrapRevoke => features.contains(&Feature::Wallet), + + // `publisher` — hand-off endpoint (§6.1 L2339; rest-surface #23). + ServedSurface::PublishSpendrecord => features.contains(&Feature::Publisher), + + // §7.4 Blossom: store must be configured, and at least one of + // `wallet` / `explorer` must be on (blob fetch under explorer, + // upload under both). No DELETE — data permanence. + ServedSurface::BlossomGet + | ServedSurface::BlossomHead + | ServedSurface::BlossomUpload => { + blossom_configured + && (features.contains(&Feature::Wallet) + || features.contains(&Feature::Explorer)) + } + } + } + + /// Surfaces active for this process given enabled features and Blossom. + fn active(features: &BTreeSet, blossom_configured: bool) -> Vec { + Self::ALL + .iter() + .copied() + .filter(|s| s.is_active(features, blossom_configured)) + .collect() + } + + /// Closed §7.5 discovery key for this surface. + fn discovery_key(self) -> &'static str { + match self { + ServedSurface::Health => "health", + ServedSurface::HealthReady => "health_ready", + ServedSurface::Info => "info", + ServedSurface::ChainAccumulator => "chain_accumulator", + ServedSurface::ChainInscriptions => "chain_inscriptions", + ServedSurface::ChainNullifier => "chain_nullifier", + ServedSurface::Tx => "tx", + ServedSurface::Jobs => "jobs", + ServedSurface::JobsStream => "jobs_stream", + ServedSurface::JobsSign => "jobs_sign", + ServedSurface::JobsCancel => "jobs_cancel", + ServedSurface::AttestBalanceChallenge => "attest_balance_challenge", + ServedSurface::AttestBalance => "attest_balance", + ServedSurface::GrantsChallenge => "grants_challenge", + ServedSurface::Grants => "grants", + ServedSurface::PullChallenge => "pull_challenge", + ServedSurface::Pull => "pull", + ServedSurface::Record => "record", + ServedSurface::Proof => "proof", + ServedSurface::AccountState => "account_state", + ServedSurface::ReceiptsStream => "receipts_stream", + ServedSurface::PublishSpendrecord => "publish_spendrecord", + ServedSurface::BootstrapChallenge => "bootstrap_challenge", + ServedSurface::BootstrapEntrust => "bootstrap_entrust", + ServedSurface::BootstrapRevoke => "bootstrap_revoke", + ServedSurface::BlossomGet => "blossom_get", + ServedSurface::BlossomHead => "blossom_head", + ServedSurface::BlossomUpload => "blossom_upload", + } + } + + /// Attach this surface's handler to the router at the axum matcher path. + /// + /// Discovery still advertises the inventory (Spec) form; only the route + /// table sees the rewritten matcher. + fn register(self, router: Router, max_blob_bytes: Option) -> Router { + let path = advertised_path_to_axum_matcher(closed_path(self.discovery_key())); + match self { + ServedSurface::Health => router.route(&path, get(health)), + ServedSurface::HealthReady => router.route(&path, get(info::health_ready)), + ServedSurface::Info => router.route(&path, get(info::get_info)), + ServedSurface::ChainAccumulator => router.route(&path, get(chain::get_accumulator)), + ServedSurface::ChainInscriptions => router.route(&path, get(chain::list_inscriptions)), + ServedSurface::ChainNullifier => router.route(&path, get(chain::get_nullifier)), + ServedSurface::Tx => router.route(&path, post(jobs::post_tx)), + ServedSurface::Jobs => router.route(&path, get(jobs::get_job)), + ServedSurface::JobsStream => router.route(&path, get(jobs::stream_job)), + ServedSurface::JobsSign => router.route(&path, post(jobs::post_sign)), + ServedSurface::JobsCancel => router.route(&path, post(jobs::post_cancel)), + ServedSurface::AttestBalanceChallenge => { + router.route(&path, post(attest::post_attest_balance_challenge)) + } + ServedSurface::AttestBalance => router.route(&path, post(attest::post_attest_balance)), + ServedSurface::GrantsChallenge => { + router.route(&path, post(grants::post_grants_challenge)) + } + ServedSurface::Grants => router.route(&path, post(grants::post_grants)), + ServedSurface::PullChallenge => router.route(&path, post(pull::post_pull_challenge)), + ServedSurface::Pull => router.route(&path, post(pull::post_pull)), + ServedSurface::Record => router.route(&path, get(pull::get_record)), + ServedSurface::Proof => router.route(&path, get(pull::get_proof)), + ServedSurface::AccountState => router.route(&path, get(pull::get_account_state)), + ServedSurface::ReceiptsStream => router.route(&path, get(pull::stream_receipts)), + ServedSurface::PublishSpendrecord => { + router.route(&path, post(publish::post_publish_spendrecord)) + } + ServedSurface::BootstrapChallenge => { + router.route(&path, post(bootstrap::post_bootstrap_challenge)) + } + ServedSurface::BootstrapEntrust => { + router.route(&path, post(bootstrap::post_bootstrap_entrust)) + } + ServedSurface::BootstrapRevoke => { + router.route(&path, post(bootstrap::post_bootstrap_revoke)) + } + // GET / HEAD share `/blossom/:sha256`; axum merges methods. + // No DELETE — data permanence (append-only store). + ServedSurface::BlossomGet => router.route(&path, get(blossom::get_blob)), + ServedSurface::BlossomHead => router.route(&path, head(blossom::head_blob)), + ServedSurface::BlossomUpload => { + // Cap buffering at the advertised max. Bodies above that are + // rejected by LimitedBytes / DefaultBodyLimit as §7.5 + // `payload_too_large` (including sizes far above max, not only + // max+1). The handler still double-checks length. + let limit = max_blob_bytes.unwrap_or(0); + let limit = usize::try_from(limit).unwrap_or(usize::MAX); + // Use at least 1 so DefaultBodyLimit::max(0) is never installed + // for a misconfigured path (upload is only active with max>0). + let limit = limit.max(1); + router.route( + &path, + put(blossom::upload_blob) + .post(blossom::upload_blob) + .layer(DefaultBodyLimit::max(limit)), + ) + } + } + } + + /// Register a known-but-inactive surface as `404 feature_disabled`. + /// + /// Same methods and path matchers as [`Self::register`], so a disabled + /// feature is still *recognised* (not a bare axum 404) while staying + /// absent from `GET /` discovery. + fn register_disabled(self, router: Router) -> Router { + let path = advertised_path_to_axum_matcher(closed_path(self.discovery_key())); + match self { + ServedSurface::Health | ServedSurface::HealthReady | ServedSurface::Info => { + // Always-on surfaces are never disabled. + router + } + ServedSurface::ChainAccumulator + | ServedSurface::ChainInscriptions + | ServedSurface::ChainNullifier + | ServedSurface::Jobs + | ServedSurface::JobsStream + | ServedSurface::Record + | ServedSurface::Proof + | ServedSurface::AccountState + | ServedSurface::ReceiptsStream + | ServedSurface::BlossomGet => router.route(&path, get(feature_disabled_handler)), + ServedSurface::BlossomHead => router.route(&path, head(feature_disabled_handler)), + ServedSurface::Tx + | ServedSurface::JobsSign + | ServedSurface::JobsCancel + | ServedSurface::AttestBalanceChallenge + | ServedSurface::AttestBalance + | ServedSurface::GrantsChallenge + | ServedSurface::Grants + | ServedSurface::PullChallenge + | ServedSurface::Pull + | ServedSurface::PublishSpendrecord + | ServedSurface::BootstrapChallenge + | ServedSurface::BootstrapEntrust + | ServedSurface::BootstrapRevoke => router.route(&path, post(feature_disabled_handler)), + ServedSurface::BlossomUpload => router.route( + &path, + put(feature_disabled_handler).post(feature_disabled_handler), + ), + } + } +} + +/// §7.5 / §6.1: known inventory path whose role feature is off for this +/// deployment. Not used for unconfigured Blossom (those paths stay unregistered). +async fn feature_disabled_handler() -> ApiError { + ApiError::feature_disabled("this endpoint is not enabled on this deployment (feature_disabled)") +} + +/// Look up the canonical **advertised** path for a closed §7.5 key. +/// +/// Returns Spec-Schreibweise (`` placeholders). Never the axum matcher +/// form — that is derived only at registration time. +/// +/// Panics if `key` is absent from [`CLOSED_ENDPOINT_KEYS`]: a served key +/// without an inventory entry is a programming error, not an empty path. +fn closed_path(key: &str) -> &'static str { + for &(k, path) in CLOSED_ENDPOINT_KEYS { + if k == key { + return path; + } + } + panic!( + "discovery key {key:?} is not in CLOSED_ENDPOINT_KEYS; \ + served surfaces must be a subset of the §7.5 inventory" + ); +} + +/// Rewrite a §7.5 advertised path into an axum 0.7 / matchit 0.7 route pattern. +/// +/// Spec writes path parameters as ``. Axum 0.7 (via matchit 0.7) treats +/// only `:name` as a dynamic segment — `{name}` and `` are literal bytes +/// in the radix tree. One projection from the inventory string; no second path +/// list. +/// +/// Panics on an unclosed `<` or an empty parameter name: inventory corruption +/// is a programming error, not a runtime soft-fail. +fn advertised_path_to_axum_matcher(advertised: &str) -> String { + let mut out = String::with_capacity(advertised.len()); + let mut rest = advertised; + while let Some(open) = rest.find('<') { + let (before, after_open) = rest.split_at(open); + out.push_str(before); + let after_open = &after_open[1..]; + let close = match after_open.find('>') { + Some(i) => i, + None => panic!("advertised path has unclosed '<' placeholder: {advertised:?}"), + }; + let name = &after_open[..close]; + if name.is_empty() { + panic!("advertised path has empty '<>' placeholder: {advertised:?}"); + } + if name.contains('/') || name.contains('<') { + panic!( + "advertised path placeholder must be a single segment name, got {name:?} in {advertised:?}" + ); + } + // axum 0.7 / matchit 0.7 named parameter: colon + name (e.g. ":job_id"). + out.push(':'); + out.push_str(name); + rest = &after_open[close + 1..]; + } + out.push_str(rest); + out +} + +/// Build the `endpoints` map for `GET /` from the active surface set. +fn discovery_endpoints( + features: &BTreeSet, + blossom_configured: bool, +) -> BTreeMap<&'static str, &'static str> { + let mut endpoints = BTreeMap::new(); + for surface in ServedSurface::active(features, blossom_configured) { + let key = surface.discovery_key(); + let path = closed_path(key); + endpoints.insert(key, path); + } + endpoints +} + +#[derive(Debug, Serialize)] +struct RootResponse { + name: &'static str, + version: &'static str, + endpoints: BTreeMap<&'static str, &'static str>, +} + +/// Build the axum router for the given configuration and kernel handle. +/// +/// Route registration follows the full inventory: active surfaces get real +/// handlers; known-but-inactive surfaces get `404 feature_disabled` stubs. +/// `GET /` discovery lists only the active set. `config.features` is also +/// stored in [`AppState`] for the API-owned `features` array on `GET /v1/info`. +/// +/// Returns a fully state-bound router (`Router` / `Router<()>`). Only that +/// form implements `tower::Service` and is ready for `axum::serve` and test +/// `oneshot` calls. Handlers extract `State` or +/// `State` (via [`axum::extract::FromRef`]); the concrete +/// state is supplied once at the end. +/// +/// # Errors +/// +/// Returns [`StartupError`] if Blossom is configured but the store root +/// cannot be opened — boot-time misconfiguration, same fail-closed class as +/// other start errors in `main` (no panic). +pub fn build_router(config: Config, kernel: KernelHandle) -> Result { + let Config { + bind_addr: _, + kernel_addr: _, + features, + public_hosts, + blossom, + } = config; + + let max_blob_bytes = blossom.as_ref().map(|b| b.max_blob_bytes); + let blossom_state = match blossom { + None => None, + Some(cfg) => { + let state = blossom::BlossomState::from_config(&cfg).map_err(|e| { + let detail = match e.cause() { + Some(c) => c.to_string(), + None => e.body.message.clone(), + }; + StartupError { + message: format!("blossom store open failed: {detail}"), + } + })?; + Some(state) + } + }; + let blossom_configured = blossom_state.is_some(); + + let state = AppState { + kernel, + features: features.clone(), + public_hosts: Arc::new(public_hosts), + blossom: blossom_state, + subject_ops: Arc::new(crate::ownership::SubjectOpDirectory::new()), + revoked_grants: Arc::new(crate::ownership::RevokedGrantSet::new()), + }; + + // Register every inventory surface as `Router`, then bind state + // so the returned tree is `Router<()>` and implements `Service`. Binding + // earlier while still returning `Router` leaves the tree + // "missing" state and breaks both `axum::serve` and `oneshot`. + // + // Blossom without a configured store is **not** a feature-disabled stub: + // the surface simply does not exist on this deployment (bare 404, no + // methods registered). When the store *is* configured but wallet/explorer + // are off, the path is known-but-inactive → `404 feature_disabled`. + let mut router = Router::new().route("/", get(root)); + for surface in ServedSurface::ALL { + if surface.is_active(&features, blossom_configured) { + router = surface.register(router, max_blob_bytes); + } else if surface.is_blossom() && !blossom_configured { + // Leave unregistered. + } else { + router = surface.register_disabled(router); + } + } + Ok(router.with_state(state)) +} + +async fn health() -> Response { + (StatusCode::OK, "ok").into_response() +} + +async fn root(State(state): State) -> Json { + let blossom_configured = state.blossom.is_some(); + Json(RootResponse { + name: "zkcoins-api", + version: env!("CARGO_PKG_VERSION"), + endpoints: discovery_endpoints(&state.features, blossom_configured), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, Feature}; + use crate::error::ApiError; + use crate::kernel::encode_kernel_error_status; + use crate::kernel::kernel_v1::{ + AccountStateRequest, AccountStateResult, AccumulatorTip, AttestRequest, BootstrapManifest, + Challenge, CoinProofBlob, CoinProofRequest, EntrustRequest, EntrustResult, GrantRequest, + GrantResult, Info, Inscription, Job, JobEvent, JobHandle, JobRequest, + ListInscriptionsRequest, Nullifier as ProtoNullifier, NullifierPath, NullifierPathRequest, + PublishRequest, PublishResult, PullChallengeRequest, PullRequest, + PullResult as ProtoPullResult, Receipt, RecordBlob, RecordRequest, RevokeRequest, + RevokeResult, SignRequest, SubscribeReceiptsRequest, TransitionRequest, + }; + use crate::kernel::KernelRpc; + use crate::ownership::SessionAuthority; + use async_trait::async_trait; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use futures_util::stream::{self, BoxStream}; + use http_body_util::BodyExt; + use serde_json::Value; + use std::collections::{BTreeSet, HashMap}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + use tonic::Code; + use tower::ServiceExt; + + /// Default test config enables every §7.5 role feature so handler tests + /// exercise the full surface. Feature-gating tests build a narrower set. + fn test_config() -> Config { + Config { + bind_addr: "127.0.0.1:0".parse().unwrap(), + kernel_addr: "http://127.0.0.1:50051".to_string(), + features: BTreeSet::from([Feature::Wallet, Feature::Explorer, Feature::Publisher]), + public_hosts: vec!["node.example.com".to_string()], + blossom: None, + } + } + + /// Config with no optional features — only always-on process surfaces. + fn test_config_no_features() -> Config { + Config { + bind_addr: "127.0.0.1:0".parse().unwrap(), + kernel_addr: "http://127.0.0.1:50051".to_string(), + features: BTreeSet::new(), + public_hosts: vec!["node.example.com".to_string()], + blossom: None, + } + } + + /// Kernel double that never succeeds — used by discovery/health tests. + struct UnreachableKernel; + + #[async_trait] + impl KernelRpc for UnreachableKernel { + async fn submit_transition(&self, _req: TransitionRequest) -> Result { + Err(ApiError::internal("test double: submit not configured")) + } + async fn get_job(&self, _req: JobRequest) -> Result { + Err(ApiError::internal("test double: get_job not configured")) + } + async fn stream_job( + &self, + _req: JobRequest, + ) -> Result>, ApiError> { + Err(ApiError::internal("test double: stream_job not configured")) + } + async fn sign_transition(&self, _req: SignRequest) -> Result { + Err(ApiError::internal("test double: sign not configured")) + } + async fn cancel_job(&self, _req: JobRequest) -> Result { + Err(ApiError::internal("test double: cancel not configured")) + } + async fn get_info(&self) -> Result { + Err(ApiError::internal("test double: get_info not configured")) + } + async fn get_accumulator(&self) -> Result { + Err(ApiError::internal( + "test double: get_accumulator not configured", + )) + } + async fn list_inscriptions( + &self, + _req: ListInscriptionsRequest, + ) -> Result>, ApiError> { + Err(ApiError::internal( + "test double: list_inscriptions not configured", + )) + } + async fn get_nullifier_path( + &self, + _req: NullifierPathRequest, + ) -> Result { + Err(ApiError::internal( + "test double: get_nullifier_path not configured", + )) + } + async fn open_pull_challenge( + &self, + _req: PullChallengeRequest, + ) -> Result { + Err(ApiError::internal( + "test double: open_pull_challenge not configured", + )) + } + async fn attest_balance(&self, _req: AttestRequest) -> Result { + Err(ApiError::internal( + "test double: attest_balance not configured", + )) + } + async fn issue_view_grant(&self, _req: GrantRequest) -> Result { + Err(ApiError::internal( + "test double: issue_view_grant not configured", + )) + } + async fn pull( + &self, + _req: PullRequest, + _authority: SessionAuthority, + ) -> Result { + Err(ApiError::internal("test double: pull not configured")) + } + async fn get_record(&self, _req: RecordRequest) -> Result { + Err(ApiError::internal("test double: get_record not configured")) + } + async fn get_coin_proof(&self, _req: CoinProofRequest) -> Result { + Err(ApiError::internal( + "test double: get_coin_proof not configured", + )) + } + async fn get_account_state( + &self, + _req: AccountStateRequest, + ) -> Result { + Err(ApiError::internal( + "test double: get_account_state not configured", + )) + } + async fn subscribe_receipts( + &self, + _req: SubscribeReceiptsRequest, + ) -> Result>, ApiError> { + Err(ApiError::internal( + "test double: subscribe_receipts not configured", + )) + } + async fn entrust_operational_bundle( + &self, + _req: EntrustRequest, + ) -> Result { + Err(ApiError::internal("test double: entrust not configured")) + } + async fn revoke_operational_bundle( + &self, + _req: RevokeRequest, + ) -> Result { + Err(ApiError::internal("test double: revoke not configured")) + } + async fn publish(&self, _req: PublishRequest) -> Result { + Err(ApiError::internal("test double: publish not configured")) + } + } + + fn test_app() -> Router { + build_router(test_config(), Arc::new(UnreachableKernel)).expect("router") + } + + async fn body_bytes(res: axum::response::Response) -> Vec { + res.into_body() + .collect() + .await + .expect("body") + .to_bytes() + .to_vec() + } + + /// Spec §7.5 L2874 closed keys in order — inventory check only. + const SPEC_CLOSED_KEYS: &[&str] = &[ + "health", + "health_ready", + "info", + "chain_accumulator", + "chain_inscriptions", + "chain_nullifier", + "tx", + "jobs", + "jobs_stream", + "jobs_sign", + "jobs_cancel", + "attest_balance_challenge", + "attest_balance", + "grants_challenge", + "grants", + "pull_challenge", + "pull", + "record", + "proof", + "account_state", + "receipts_stream", + "publish_spendrecord", + "bootstrap_challenge", + "bootstrap_entrust", + "bootstrap_revoke", + "blossom_get", + "blossom_head", + "blossom_upload", + ]; + + #[test] + fn closed_endpoint_keys_inventory_matches_spec() { + assert_eq!( + CLOSED_ENDPOINT_KEYS.len(), + 28, + "CLOSED_ENDPOINT_KEYS must list all 28 §7.5 closed keys (no blossom_delete)" + ); + assert_eq!( + SPEC_CLOSED_KEYS.len(), + 28, + "spec key list fixture must stay in sync with closed inventory" + ); + for (i, (key, path)) in CLOSED_ENDPOINT_KEYS.iter().enumerate() { + assert_eq!( + *key, SPEC_CLOSED_KEYS[i], + "CLOSED_ENDPOINT_KEYS[{i}] key must match §7.5 L2874 order" + ); + assert!( + !path.is_empty(), + "inventory path for key {key} must be non-empty" + ); + assert!( + path.starts_with('/'), + "inventory path for key {key} must be root-relative, got {path:?}" + ); + assert!( + !path.contains('{') && !path.contains('}'), + "inventory path for key {key} must use Spec form, not braces: {path:?}" + ); + } + let keys: BTreeSet<&str> = CLOSED_ENDPOINT_KEYS.iter().map(|(k, _)| *k).collect(); + assert!(!keys.contains(""), "empty discovery key is invalid"); + assert_eq!(keys.len(), 28, "closed keys must be unique"); + assert!( + !keys.contains("blossom_delete"), + "data permanence: blossom_delete must not be in the inventory" + ); + } + + #[test] + fn every_served_surface_is_in_closed_inventory() { + // Full feature set + Blossom store: every inventory surface must map. + let features = BTreeSet::from([Feature::Wallet, Feature::Explorer, Feature::Publisher]); + for surface in ServedSurface::active(&features, true) { + let key = surface.discovery_key(); + let path = closed_path(key); + assert!( + !path.is_empty(), + "served key {key} must resolve to a non-empty inventory path" + ); + } + assert_eq!( + ServedSurface::active(&features, true).len(), + ServedSurface::ALL.len(), + "wallet+explorer+publisher+blossom must activate the full inventory" + ); + } + + #[tokio::test] + async fn health_returns_200_ok_plaintext() { + let app = test_app(); + let res = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = body_bytes(res).await; + assert_eq!( + body, + b"ok", + "health body must be exactly the bytes of \"ok\", got {:?}", + String::from_utf8_lossy(&body) + ); + } + + #[tokio::test] + async fn root_advertises_exactly_the_served_surfaces() { + let app = test_app(); + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = body_bytes(res).await; + let json: Value = serde_json::from_slice(&body).expect("JSON root body"); + + assert_eq!(json["name"], "zkcoins-api"); + assert_eq!(json["version"], env!("CARGO_PKG_VERSION")); + + let endpoints = json["endpoints"].as_object().expect("endpoints object"); + + let cfg = test_config(); + let expected_keys: BTreeSet<&str> = ServedSurface::active(&cfg.features, false) + .iter() + .map(|s| s.discovery_key()) + .collect(); + let actual_keys: BTreeSet<&str> = endpoints.keys().map(|s| s.as_str()).collect(); + assert_eq!( + actual_keys, expected_keys, + "GET / must list exactly the served surfaces, not the full inventory" + ); + assert_eq!( + actual_keys, + BTreeSet::from([ + "health", + "health_ready", + "info", + "chain_accumulator", + "chain_inscriptions", + "chain_nullifier", + "tx", + "jobs", + "jobs_stream", + "jobs_sign", + "jobs_cancel", + "attest_balance_challenge", + "attest_balance", + "grants_challenge", + "grants", + "pull_challenge", + "pull", + "record", + "proof", + "account_state", + "receipts_stream", + "publish_spendrecord", + "bootstrap_challenge", + "bootstrap_entrust", + "bootstrap_revoke", + ]), + "test_config (wallet+explorer+publisher, no blossom) advertises 25 keys" + ); + assert_eq!( + endpoints["bootstrap_challenge"].as_str(), + Some("/v1/bootstrap/challenge") + ); + assert_eq!( + endpoints["bootstrap_entrust"].as_str(), + Some("/v1/bootstrap/entrust") + ); + assert_eq!( + endpoints["bootstrap_revoke"].as_str(), + Some("/v1/bootstrap/revoke") + ); + assert_eq!( + endpoints["publish_spendrecord"].as_str(), + Some("/v1/publish/spendrecord") + ); + // Blossom stays off discovery without ZKCOINS_BLOSSOM_STORE. + for absent in ["blossom_get", "blossom_head", "blossom_upload"] { + assert!( + !endpoints.contains_key(absent), + "unconfigured Blossom surface {absent} must stay unadvertised" + ); + } + assert!( + !endpoints.contains_key("blossom_delete"), + "data permanence: blossom_delete must never be advertised" + ); + assert_eq!( + endpoints["receipts_stream"].as_str(), + Some("/v1/receipts/stream"), + "receipts_stream must be advertised once SubscribeReceipts is wired" + ); + assert_eq!( + endpoints["chain_inscriptions"].as_str(), + Some("/v1/chain/inscriptions"), + "chain_inscriptions must be advertised once ListInscriptions is served" + ); + assert_eq!( + endpoints["attest_balance_challenge"].as_str(), + Some("/v1/attest/balance/challenge") + ); + assert_eq!( + endpoints["attest_balance"].as_str(), + Some("/v1/attest/balance") + ); + assert_eq!( + endpoints["grants_challenge"].as_str(), + Some("/v1/grants/challenge") + ); + assert_eq!(endpoints["grants"].as_str(), Some("/v1/grants")); + assert_eq!( + endpoints["pull_challenge"].as_str(), + Some("/v1/pull/challenge") + ); + assert_eq!(endpoints["pull"].as_str(), Some("/v1/pull")); + assert_eq!(endpoints["record"].as_str(), Some("/v1/record/")); + assert_eq!(endpoints["proof"].as_str(), Some("/v1/proof/")); + assert_eq!( + endpoints["account_state"].as_str(), + Some("/v1/account/state") + ); + assert_eq!( + endpoints["receipts_stream"].as_str(), + Some("/v1/receipts/stream") + ); + // chain_inscriptions is advertised — the node catalog backs ListInscriptions. + assert!( + endpoints.contains_key("chain_inscriptions"), + "chain_inscriptions must be advertised while the node catalog is present" + ); + assert_eq!( + endpoints["health"].as_str(), + Some("/health"), + "health path must match CLOSED_ENDPOINT_KEYS inventory" + ); + assert_eq!(endpoints["health_ready"].as_str(), Some("/health/ready")); + assert_eq!(endpoints["info"].as_str(), Some("/v1/info")); + assert_eq!( + endpoints["chain_accumulator"].as_str(), + Some("/v1/chain/accumulator") + ); + // Spec-Schreibweise on the wire — never the axum matcher form. + assert_eq!( + endpoints["chain_nullifier"].as_str(), + Some("/v1/chain/nullifier/") + ); + assert_eq!(endpoints["tx"].as_str(), Some("/v1/tx")); + assert_eq!(endpoints["jobs"].as_str(), Some("/v1/jobs/")); + assert_eq!( + endpoints["jobs_stream"].as_str(), + Some("/v1/jobs//stream") + ); + assert_eq!( + endpoints["jobs_sign"].as_str(), + Some("/v1/jobs//sign") + ); + assert_eq!( + endpoints["jobs_cancel"].as_str(), + Some("/v1/jobs//cancel") + ); + } + + #[test] + fn advertised_path_to_axum_matcher_rewrites_angle_brackets() { + assert_eq!( + advertised_path_to_axum_matcher("/v1/jobs/"), + "/v1/jobs/:job_id" + ); + assert_eq!( + advertised_path_to_axum_matcher("/v1/jobs//stream"), + "/v1/jobs/:job_id/stream" + ); + assert_eq!( + advertised_path_to_axum_matcher("/v1/chain/nullifier/"), + "/v1/chain/nullifier/:pubkey" + ); + assert_eq!(advertised_path_to_axum_matcher("/health"), "/health"); + assert_eq!(advertised_path_to_axum_matcher("/v1/tx"), "/v1/tx"); + // Every inventory path must round-trip into a matcher without leftover + // Spec placeholders (guards against a second hand-written list). + for &(key, path) in CLOSED_ENDPOINT_KEYS { + let matcher = advertised_path_to_axum_matcher(path); + assert!( + !matcher.contains('<') && !matcher.contains('>'), + "key {key}: matcher still has Spec brackets: {matcher}" + ); + assert!( + !matcher.contains('{') && !matcher.contains('}'), + "key {key}: matcher must not use brace params (axum 0.8); got {matcher}" + ); + } + } + + /// Concrete segment for an advertised `` placeholder. + /// + /// Values are plausible for the handlers that extract the segment (job_id + /// is opaque text; pubkey / hashes are 32-byte hex). Unknown names fail + /// loud — the inventory must not invent slots without a probe value. + fn concrete_path_param(name: &str) -> &'static str { + match name { + "job_id" => "00000000-0000-4000-8000-000000000001", + "pubkey" | "sha256" | "coin_id" | "record_id" => { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + other => panic!( + "no concrete probe value for path parameter {other:?}; \ + extend concrete_path_param when the inventory gains this slot" + ), + } + } + + /// Replace every `` in an advertised path with a concrete segment. + fn concrete_probe_uri(advertised: &str) -> String { + let mut out = String::with_capacity(advertised.len() + 32); + let mut rest = advertised; + while let Some(open) = rest.find('<') { + let (before, after_open) = rest.split_at(open); + out.push_str(before); + let after_open = &after_open[1..]; + let close = match after_open.find('>') { + Some(i) => i, + None => panic!("unclosed '<' in advertised path {advertised:?}"), + }; + let name = &after_open[..close]; + out.push_str(concrete_path_param(name)); + rest = &after_open[close + 1..]; + } + out.push_str(rest); + out + } + + /// `true` when the body is a §7.5 domain error (`{ "error", "message" }`) + /// with a non-empty machine code. Axum's routing fallback is status-only + /// (empty body) — that is **not** a domain answer. + fn is_section_75_error_body(body: &[u8]) -> bool { + let Ok(json) = serde_json::from_slice::(body) else { + return false; + }; + matches!( + json.get("error").and_then(|v| v.as_str()), + Some(code) if !code.is_empty() + ) + } + + /// Would have been **red** when registration used the advertised string as + /// a literal axum path: the probe hits a *concrete* URI, so a route table + /// that only matches the Spec placeholder text answers with the empty + /// axum fallback 404 — distinguishable from a domain 404 that carries the + /// §7.5 `{ "error", "message" }` body. + #[tokio::test] + async fn every_advertised_endpoint_is_reachable() { + let discovery = { + let app = test_app(); + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = body_bytes(res).await; + let json: Value = serde_json::from_slice(&body).expect("JSON root body"); + let endpoints = json["endpoints"].as_object().expect("endpoints object"); + endpoints + .iter() + .map(|(k, v)| { + let path = v.as_str().expect("endpoint value must be a string path"); + assert!( + !path.is_empty(), + "advertised path for key {k} must not be empty" + ); + (k.clone(), path.to_string()) + }) + .collect::>() + }; + + assert!( + !discovery.is_empty(), + "GET / must advertise at least one served surface" + ); + + for (key, advertised) in &discovery { + let probe = concrete_probe_uri(advertised); + assert!( + !probe.contains('<') && !probe.contains('>'), + "probe URI for {key} still has a template placeholder: {probe}" + ); + + let app = test_app(); + let res = app + .oneshot( + Request::builder() + .uri(probe.as_str()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = res.status(); + if status == StatusCode::NOT_FOUND { + let body = body_bytes(res).await; + assert!( + is_section_75_error_body(&body), + "GET / advertised key {key:?} at {advertised:?}; probe {probe:?} \ + returned a routing 404 (no §7.5 error body, got {:?}) — the \ + matcher was never registered for a concrete segment", + String::from_utf8_lossy(&body) + ); + // Domain 404 (handler ran, returned job_not_found etc.) is fine. + } + // Any non-404 (200, 405 method, 500 from the unreachable kernel double, + // 400, …) means the route matched. That is the reachability claim. + } + } + + #[tokio::test] + async fn chain_inscriptions_is_404_and_absent_from_discovery() { + // Renamed historically: the route is registered, returns a page, and + // the discovery key is present. The node catalog backs ListInscriptions. + let kernel = ScriptedKernel { + list_inscriptions: Some(Ok(Vec::new())), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/chain/inscriptions") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::OK, + "GET /v1/chain/inscriptions must be registered and return a page" + ); + let body = body_bytes(res).await; + let json: Value = serde_json::from_slice(&body).expect("JSON page body"); + assert_eq!( + json["inscriptions"], + serde_json::json!([]), + "empty catalog is an empty list, not 404" + ); + assert!( + json.get("next_height").is_none() + && json.get("next_tx_index").is_none() + && json.get("next_vin_index").is_none(), + "empty page must omit all three next_* fields, got {json}" + ); + + let app = test_app(); + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let body = body_bytes(res).await; + let json: Value = serde_json::from_slice(&body).expect("JSON root body"); + let endpoints = json["endpoints"].as_object().expect("endpoints object"); + assert!( + endpoints.contains_key("chain_inscriptions"), + "served surface 'chain_inscriptions' must appear in GET / endpoints" + ); + assert_eq!( + endpoints["chain_inscriptions"].as_str(), + Some("/v1/chain/inscriptions") + ); + assert!( + endpoints.contains_key("info"), + "stage B must advertise info" + ); + assert!( + endpoints.contains_key("health_ready"), + "stage B must advertise health_ready" + ); + assert!( + endpoints.contains_key("chain_nullifier"), + "stage B must advertise chain_nullifier" + ); + } + + #[tokio::test] + async fn router_accepts_config_with_features() { + let mut features = BTreeSet::new(); + features.insert(Feature::Wallet); + let cfg = Config { + bind_addr: "127.0.0.1:0".parse().unwrap(), + kernel_addr: "http://kernel:1".to_string(), + features, + public_hosts: vec!["node.example.com".to_string()], + blossom: None, + }; + let app = build_router(cfg, Arc::new(UnreachableKernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + + // Wallet alone opens the job/pull surfaces and omits explorer/publisher. + let app = build_router( + Config { + bind_addr: "127.0.0.1:0".parse().unwrap(), + kernel_addr: "http://kernel:1".to_string(), + features: BTreeSet::from([Feature::Wallet]), + public_hosts: vec!["node.example.com".to_string()], + blossom: None, + }, + Arc::new(UnreachableKernel), + ) + .expect("router"); + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let body = body_bytes(res).await; + let json: Value = serde_json::from_slice(&body).expect("JSON root body"); + let endpoints = json["endpoints"].as_object().expect("endpoints object"); + assert!( + endpoints.contains_key("tx"), + "wallet feature must advertise the job surface key 'tx'" + ); + assert!( + endpoints.contains_key("pull"), + "wallet feature must advertise /v1/pull" + ); + assert!( + endpoints.contains_key("receipts_stream"), + "wallet feature must advertise receipts_stream" + ); + assert!( + !endpoints.contains_key("chain_accumulator"), + "explorer surface must stay unadvertised without explorer feature" + ); + assert!( + !endpoints.contains_key("publish_spendrecord"), + "publisher surface must stay unadvertised without publisher feature" + ); + } + + /// Without the change: wallet/explorer/publisher routes were always-on, + /// so a disabled feature still returned a non-404 (kernel error / 405 / …) + /// and `GET /` still advertised the key. With the stub, disabled known + /// routes answer `404 feature_disabled` (machine code + JSON body), not a + /// bare axum 404. + #[tokio::test] + async fn disabled_wallet_surface_is_404_feature_disabled_and_absent_from_discovery() { + let app = + build_router(test_config_no_features(), Arc::new(UnreachableKernel)).expect("router"); + + // Probe a concrete wallet path — known inventory, feature off. + let res = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::NOT_FOUND, + "disabled wallet surface must not be served" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!( + json["error"], "feature_disabled", + "disabled known route must carry machine code feature_disabled, got {json}" + ); + assert!( + json.get("message").and_then(|m| m.as_str()).is_some(), + "§7.5 body must include message, got {json}" + ); + + // Unknown path (not in inventory) stays a bare framework 404 without + // the feature_disabled machine code. + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/not-an-inventory-path") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let unknown_body = body_bytes(res).await; + if let Ok(j) = serde_json::from_slice::(&unknown_body) { + assert_ne!( + j.get("error").and_then(|e| e.as_str()), + Some("feature_disabled"), + "unknown paths must not claim feature_disabled" + ); + } + + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + let endpoints = json["endpoints"].as_object().expect("endpoints object"); + assert!( + !endpoints.contains_key("tx"), + "GET / must not advertise disabled wallet key 'tx'" + ); + assert!( + !endpoints.contains_key("jobs"), + "GET / must not advertise disabled wallet key 'jobs'" + ); + // Always-on process surfaces remain. + assert!(endpoints.contains_key("health")); + assert!(endpoints.contains_key("info")); + assert_eq!( + endpoints.len(), + 3, + "no-features config must advertise only health, health_ready, info; got {:?}", + endpoints.keys().collect::>() + ); + } + + #[tokio::test] + async fn disabled_explorer_surface_is_404_and_absent_from_discovery() { + // Wallet on, explorer off: chain routes must vanish. + let cfg = Config { + bind_addr: "127.0.0.1:0".parse().unwrap(), + kernel_addr: "http://127.0.0.1:50051".to_string(), + features: BTreeSet::from([Feature::Wallet]), + public_hosts: vec!["node.example.com".to_string()], + blossom: None, + }; + let app = build_router(cfg, Arc::new(UnreachableKernel)).expect("router"); + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/chain/accumulator") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::NOT_FOUND, + "disabled explorer surface must not be served" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!( + json["error"], "feature_disabled", + "disabled explorer must carry feature_disabled machine code" + ); + + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + let endpoints = json["endpoints"].as_object().expect("endpoints object"); + assert!( + !endpoints.contains_key("chain_accumulator"), + "GET / must not advertise disabled explorer key" + ); + assert!( + endpoints.contains_key("tx"), + "wallet surface must remain advertised when only explorer is off" + ); + } + + #[tokio::test] + async fn disabled_publisher_surface_is_404_and_absent_from_discovery() { + let cfg = Config { + bind_addr: "127.0.0.1:0".parse().unwrap(), + kernel_addr: "http://127.0.0.1:50051".to_string(), + features: BTreeSet::from([Feature::Wallet, Feature::Explorer]), + public_hosts: vec!["node.example.com".to_string()], + blossom: None, + }; + let app = build_router(cfg, Arc::new(UnreachableKernel)).expect("router"); + let res = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/publish/spendrecord") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::NOT_FOUND, + "disabled publisher surface must not be served" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!( + json["error"], "feature_disabled", + "disabled publisher must carry feature_disabled machine code" + ); + + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + let endpoints = json["endpoints"].as_object().expect("endpoints object"); + assert!( + !endpoints.contains_key("publish_spendrecord"), + "GET / must not advertise disabled publisher key" + ); + } + + // ----------------------------------------------------------------------- + // Job-surface handler tests against an honest in-trait kernel double + // ----------------------------------------------------------------------- + + /// Yields scripted receipt items, then parks until dropped. + /// + /// Drop sets `dropped` so tests can prove client disconnect tears down the + /// kernel subscription (same pattern as job-stream body drop). + struct HangAfterReceipts { + items: std::vec::IntoIter>, + dropped: Arc, + } + + impl Drop for HangAfterReceipts { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } + } + + impl futures_util::Stream for HangAfterReceipts { + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.items.next() { + Some(item) => std::task::Poll::Ready(Some(item)), + // Park until the consumer drops this stream (client disconnect). + None => std::task::Poll::Pending, + } + } + } + + #[derive(Default)] + struct ScriptedKernel { + submit: Option>, + get: Option>, + stream: Option>, ApiError>>, + sign: Option>, + cancel: Option>, + info: Option>, + accumulator: Option>, + /// Full catalog; the double filters by inclusive triple + limit. + list_inscriptions: Option, ApiError>>, + nullifier_path: Option>, + open_challenge: Option>, + attest: Option>, + issue_grant: Option>, + pull: Option>, + get_record: Option>, + get_coin_proof: Option>, + get_account_state: Option>, + /// Receipts stream: handshake `Err` or a finite list of items (Ok/Err). + /// When `subscribe_receipts_hang` is true, the double yields the list + /// then parks until the stream is dropped (disconnect cleanup). + subscribe_receipts: Option>, ApiError>>, + /// After scripted items, hang until drop (for cleanup tests). + subscribe_receipts_hang: bool, + /// Set true when a hanging receipts stream is dropped. + subscribe_receipts_dropped: Arc, + entrust: Option>, + revoke: Option>, + publish: Option>, + /// Call counters for proving "no kernel call" on auth failure. + attest_calls: AtomicUsize, + issue_grant_calls: AtomicUsize, + open_challenge_calls: AtomicUsize, + pull_calls: AtomicUsize, + get_record_calls: AtomicUsize, + get_coin_proof_calls: AtomicUsize, + get_account_state_calls: AtomicUsize, + subscribe_receipts_calls: AtomicUsize, + entrust_calls: AtomicUsize, + revoke_calls: AtomicUsize, + publish_calls: AtomicUsize, + list_inscriptions_calls: AtomicUsize, + /// SubmitTransition call counter (delivery form rejections must stay 0). + submit_calls: AtomicUsize, + /// Last pull authority observed (for grant/ownership plumbing asserts). + last_pull_authority: Mutex>, + /// Last PullRequest observed (resolved_scope / subject plumbing). + last_pull: Mutex>, + /// Last OpenPullChallenge.action observed (bootstrap domain plumbing). + last_open_challenge_action: Mutex>, + /// Last entrust request (bundle length / subject checks — never log bundle). + last_entrust: Mutex>, + last_revoke: Mutex>, + last_publish: Mutex>, + /// Last ListInscriptions request (limit / cursor plumbing). + last_list_inscriptions: Mutex>, + /// Last SubscribeReceipts request (session + chan_bind; never subject). + last_subscribe_receipts: Mutex>, + /// Last SubmitTransition request (delivery field-for-field asserts). + last_submit: Mutex>, + } + + #[async_trait] + impl KernelRpc for ScriptedKernel { + async fn submit_transition(&self, req: TransitionRequest) -> Result { + self.submit_calls.fetch_add(1, Ordering::SeqCst); + *self.last_submit.lock().unwrap() = Some(req); + match &self.submit { + Some(Ok(h)) => Ok(h.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("submit not scripted")), + } + } + async fn get_job(&self, _req: JobRequest) -> Result { + match &self.get { + Some(Ok(j)) => Ok(j.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("get not scripted")), + } + } + async fn stream_job( + &self, + _req: JobRequest, + ) -> Result>, ApiError> { + match &self.stream { + Some(Ok(events)) => { + let events = events.clone(); + Ok(Box::pin(stream::iter(events))) + } + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("stream not scripted")), + } + } + async fn sign_transition(&self, _req: SignRequest) -> Result { + match &self.sign { + Some(Ok(j)) => Ok(j.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("sign not scripted")), + } + } + async fn cancel_job(&self, _req: JobRequest) -> Result { + match &self.cancel { + Some(Ok(j)) => Ok(j.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("cancel not scripted")), + } + } + async fn get_info(&self) -> Result { + match &self.info { + Some(Ok(i)) => Ok(i.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("info not scripted")), + } + } + async fn get_accumulator(&self) -> Result { + match &self.accumulator { + Some(Ok(t)) => Ok(t.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("accumulator not scripted")), + } + } + async fn list_inscriptions( + &self, + req: ListInscriptionsRequest, + ) -> Result>, ApiError> { + self.list_inscriptions_calls.fetch_add(1, Ordering::SeqCst); + // ListInscriptionsRequest is Copy (scalar Option fields only). + *self + .last_list_inscriptions + .lock() + .expect("list_inscriptions mutex") = Some(req); + match &self.list_inscriptions { + Some(Ok(catalog)) => { + // §7.5 defaults (same as API normalisation before RPC / + // ListInscriptionsRequest proto comment). Named so the + // protocol values stay visible — not unwrap_or_default(). + const DEFAULT_FROM_HEIGHT: u64 = 0; + const DEFAULT_FROM_TX_INDEX: u64 = 0; + const DEFAULT_FROM_VIN_INDEX: u64 = 0; + const DEFAULT_LIMIT: u32 = 100; + let from_h = req.from_height.unwrap_or(DEFAULT_FROM_HEIGHT); + let from_t = req.from_tx_index.unwrap_or(DEFAULT_FROM_TX_INDEX); + let from_v = req.from_vin_index.unwrap_or(DEFAULT_FROM_VIN_INDEX); + let limit = req.limit.unwrap_or(DEFAULT_LIMIT) as usize; + let items: Vec> = catalog + .iter() + .filter(|ins| { + (ins.height, ins.tx_index, ins.vin_index) >= (from_h, from_t, from_v) + }) + .take(limit) + .cloned() + .map(Ok) + .collect(); + Ok(Box::pin(stream::iter(items))) + } + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("list_inscriptions not scripted")), + } + } + async fn get_nullifier_path( + &self, + _req: NullifierPathRequest, + ) -> Result { + match &self.nullifier_path { + Some(Ok(p)) => Ok(p.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("nullifier_path not scripted")), + } + } + async fn open_pull_challenge( + &self, + req: PullChallengeRequest, + ) -> Result { + self.open_challenge_calls.fetch_add(1, Ordering::SeqCst); + *self + .last_open_challenge_action + .lock() + .expect("open action mutex") = Some(req.action); + match &self.open_challenge { + Some(Ok(c)) => Ok(c.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("open_challenge not scripted")), + } + } + async fn attest_balance(&self, _req: AttestRequest) -> Result { + self.attest_calls.fetch_add(1, Ordering::SeqCst); + match &self.attest { + Some(Ok(h)) => Ok(h.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("attest not scripted")), + } + } + async fn issue_view_grant(&self, _req: GrantRequest) -> Result { + self.issue_grant_calls.fetch_add(1, Ordering::SeqCst); + match &self.issue_grant { + Some(Ok(r)) => Ok(r.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("issue_grant not scripted")), + } + } + async fn pull( + &self, + req: PullRequest, + authority: SessionAuthority, + ) -> Result { + self.pull_calls.fetch_add(1, Ordering::SeqCst); + *self.last_pull_authority.lock().expect("authority mutex") = Some(authority); + *self.last_pull.lock().expect("pull mutex") = Some(req); + match &self.pull { + Some(Ok(r)) => Ok(r.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("pull not scripted")), + } + } + async fn get_record(&self, _req: RecordRequest) -> Result { + self.get_record_calls.fetch_add(1, Ordering::SeqCst); + match &self.get_record { + Some(Ok(r)) => Ok(r.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("get_record not scripted")), + } + } + async fn get_coin_proof(&self, _req: CoinProofRequest) -> Result { + self.get_coin_proof_calls.fetch_add(1, Ordering::SeqCst); + match &self.get_coin_proof { + Some(Ok(r)) => Ok(r.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("get_coin_proof not scripted")), + } + } + async fn get_account_state( + &self, + _req: AccountStateRequest, + ) -> Result { + self.get_account_state_calls.fetch_add(1, Ordering::SeqCst); + match &self.get_account_state { + Some(Ok(r)) => Ok(r.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("get_account_state not scripted")), + } + } + async fn subscribe_receipts( + &self, + req: SubscribeReceiptsRequest, + ) -> Result>, ApiError> { + self.subscribe_receipts_calls.fetch_add(1, Ordering::SeqCst); + *self + .last_subscribe_receipts + .lock() + .expect("subscribe_receipts mutex") = Some(req); + match &self.subscribe_receipts { + Some(Ok(events)) => { + let events = events.clone(); + if self.subscribe_receipts_hang { + let dropped = Arc::clone(&self.subscribe_receipts_dropped); + Ok(Box::pin(HangAfterReceipts { + items: events.into_iter(), + dropped, + })) + } else { + Ok(Box::pin(stream::iter(events))) + } + } + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("subscribe_receipts not scripted")), + } + } + async fn entrust_operational_bundle( + &self, + req: EntrustRequest, + ) -> Result { + self.entrust_calls.fetch_add(1, Ordering::SeqCst); + *self.last_entrust.lock().expect("entrust mutex") = Some(req); + match &self.entrust { + Some(Ok(r)) => Ok(*r), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("entrust not scripted")), + } + } + async fn revoke_operational_bundle( + &self, + req: RevokeRequest, + ) -> Result { + self.revoke_calls.fetch_add(1, Ordering::SeqCst); + *self.last_revoke.lock().expect("revoke mutex") = Some(req); + match &self.revoke { + Some(Ok(r)) => Ok(*r), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("revoke not scripted")), + } + } + async fn publish(&self, req: PublishRequest) -> Result { + self.publish_calls.fetch_add(1, Ordering::SeqCst); + *self.last_publish.lock().expect("publish mutex") = Some(req); + match &self.publish { + Some(Ok(r)) => Ok(r.clone()), + Some(Err(e)) => Err(e.clone()), + None => Err(ApiError::internal("publish not scripted")), + } + } + } + + fn sample_info(ready: bool, reason: Option<&str>) -> Info { + let mut circuit_digests = HashMap::new(); + circuit_digests.insert("C".to_string(), vec![0x11; 32]); + circuit_digests.insert("C_balance".to_string(), vec![0x22; 32]); + Info { + network: "regtest".into(), + protocol_version: "v1".into(), + circuit_digests, + relay_url: "wss://relay.example".into(), + blossom_url: "https://blossom.example".into(), + finality_confirmations: 6, + max_tx_inputs: 8, + max_tx_outputs: 8, + max_rx_coins: 4, + max_account_assets: 32, + ready, + bitcoin_tip_height: 100, + accumulator_root: vec![0xAA; 32], + scanner_lag: 0, + max_blob_bytes: 1_048_576, + activation_height: 0, + bootstrap: Some(BootstrapManifest { + network: "regtest".into(), + protocol_version: "v1".into(), + seed_relays: vec!["wss://seed.example".into()], + blob_stores: vec!["https://blob.example".into()], + operator_ids: vec![vec![0x33; 32]], + issued_at: 1, + expires_at: 9_999_999_999, + manifest_sig: vec![0x44; 64], + }), + kernel_parts: vec!["scanner".into()], + ready_reason: reason.map(|s| s.to_string()), + bootstrap_pubkey: vec![0x55; 32], + } + } + + fn hex32(byte: u8) -> String { + crate::hexutil::encode_hex(&[byte; 32]) + } + + fn mint_body() -> Value { + json_mint() + } + + fn json_mint() -> Value { + serde_json::json!({ + "kind": "mint", + "subject": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "next_pubkey": hex32(0x11), + "npk_rand": hex32(0x22), + "output_templates": [{ + "recipient": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "asset_id": hex32(0x33), + "amount": "100" + }], + "issuance": { + "name": "TestCoin", + "decimals": 8, + "issuance_version": 1, + "amount": "1000", + "creator_pubkey": hex32(0x44) + } + }) + } + + fn accepted_job(job_id: &str) -> Job { + Job { + job_id: job_id.to_string(), + kind: "mint".to_string(), + status: "accepted".to_string(), + phase: String::new(), + progress: 0.0, + awaiting_signature: None, + result: None, + error: None, + } + } + + #[tokio::test] + async fn post_tx_happy_path_returns_202() { + let kernel = ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "job-1".to_string(), + status: "accepted".to_string(), + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .header("idempotency-key", "k1") + .body(Body::from(mint_body().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::ACCEPTED); + let body = body_bytes(res).await; + let json: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(json["job_id"], "job-1"); + assert_eq!(json["status"], "accepted"); + } + + /// Missing `Idempotency-Key` is optional: request reaches the kernel and + /// may succeed. Distinct from a present-but-empty header (next test). + #[tokio::test] + async fn post_tx_missing_idempotency_key_is_allowed() { + let kernel = ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "job-no-key".to_string(), + status: "accepted".to_string(), + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + // deliberately no Idempotency-Key + .body(Body::from(mint_body().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::ACCEPTED, + "absent Idempotency-Key must not be rewritten into a client error" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["job_id"], "job-no-key"); + } + + /// Present-but-empty `Idempotency-Key` is `400 malformed_request`. + /// + /// Asserts the two outcomes diverge: missing → `Ok(None)`, empty value → + /// `400 malformed_request`. `http::HeaderValue` cannot encode a zero-byte + /// value, so the empty branch is exercised through the value parser rather + /// than a crafted HTTP request; the missing path is also covered by + /// `post_tx_missing_idempotency_key_is_allowed` at HTTP level. + #[test] + fn post_tx_empty_vs_missing_idempotency_key_diverge() { + // Missing → Ok(None) → not a client error. + let headers = axum::http::HeaderMap::new(); + assert!(crate::jobs::idempotency_key_from_headers(&headers) + .expect("missing ok") + .is_none()); + // Empty value → 400 malformed_request (never Ok(Some(""))). + let err = crate::jobs::parse_idempotency_key_value("").expect_err("empty must error"); + assert_eq!(err.status, StatusCode::BAD_REQUEST); + assert_eq!(err.body.error, "malformed_request"); + } + + /// Unknown top-level field must be `400 malformed_request`, not ignored. + #[tokio::test] + async fn post_tx_unknown_top_level_field_is_malformed_400() { + let kernel = ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let mut body = mint_body(); + body["extra_unknown"] = Value::String("nope".into()); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "unknown field must be 400, not 422 or silent drop" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + } + + /// Unknown field inside a nested object (issuance) is also rejected. + #[tokio::test] + async fn post_tx_unknown_nested_field_is_malformed_400() { + let kernel = ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let mut body = mint_body(); + body["issuance"]["foreign_nested"] = Value::Number(1.into()); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::BAD_REQUEST, + "nested unknown field must be 400, not silently dropped" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + } + + /// Empty job_id from the kernel must not become a client-visible 202. + #[tokio::test] + async fn post_tx_empty_job_id_from_kernel_is_not_202() { + let kernel = ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: String::new(), + status: "accepted".to_string(), + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(mint_body().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!( + res.status(), + StatusCode::ACCEPTED, + "empty job_id must not be admitted as 202" + ); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "internal_error"); + assert_eq!( + json["message"], + crate::error::PUBLIC_INTERNAL_MESSAGE, + "public internal_error message must be neutral" + ); + } + + /// Unknown / non-accepted kernel status must not become a client-visible 202. + #[tokio::test] + async fn post_tx_unknown_status_from_kernel_is_not_202() { + let kernel = ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "job-weird".to_string(), + status: "totally_unknown_phase".to_string(), + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(mint_body().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!( + res.status(), + StatusCode::ACCEPTED, + "unknown status must not be admitted as 202" + ); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "internal_error"); + assert_eq!( + json["message"], + crate::error::PUBLIC_INTERNAL_MESSAGE, + "public internal_error message must be neutral" + ); + } + + /// Empty status string is also not a valid admit terminal. + #[tokio::test] + async fn post_tx_empty_status_from_kernel_is_not_202() { + let kernel = ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "job-empty-status".to_string(), + status: String::new(), + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(mint_body().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_ne!(res.status(), StatusCode::ACCEPTED); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[tokio::test] + async fn post_tx_fee_address_is_malformed_400() { + let kernel = ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let mut body = mint_body(); + body["fee_address"] = Value::String("zk1fee".into()); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + assert!( + json["message"].as_str().unwrap().contains("fee_address"), + "message must name fee_address, got {}", + json["message"] + ); + } + + #[tokio::test] + async fn post_tx_kernel_bounds_exceeded_is_400() { + // error_contract: BoundsExceeded → bounds_exceeded / 400. + let status = encode_kernel_error_status( + Code::InvalidArgument, + "too many outputs", + "bounds_exceeded", + 400, + ); + let kernel = ScriptedKernel { + submit: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(mint_body().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "bounds_exceeded"); + assert_eq!(json["message"], "too many outputs"); + } + + /// Distinctive pk0 hex used only in delivery HTTP tests — must never + /// appear in 400 response bodies (form errors name paths, not values). + fn delivery_test_pk0() -> String { + "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90".to_string() + } + + fn delivery_test_memo() -> String { + "MEMO_RETENTION_MARKER_DO_NOT_LOG_xyz".to_string() + } + + fn mint_body_with_invoice_delivery() -> Value { + let mut body = mint_body(); + body["output_templates"][0]["delivery"] = serde_json::json!({ + "type": "invoice", + "invoice": { + "amount": "100", + "recipient": "zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "asset_id": hex32(0x33), + "memo": delivery_test_memo(), + "pk0": delivery_test_pk0(), + "nk_commit": hex32(0x44), + "ivpk": hex32(0x55), + "op_pubkey": hex32(0x66), + "relays": ["wss://relay.example"], + "addr_sig": crate::hexutil::encode_hex(&[0x77u8; 64]), + "sig": crate::hexutil::encode_hex(&[0x88u8; 64]), + } + }); + body + } + + /// Well-formed invoice delivery reaches the kernel field-for-field. + #[tokio::test] + async fn post_tx_invoice_delivery_forwards_to_kernel() { + let kernel = Arc::new(ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "job-deliv".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(mint_body_with_invoice_delivery().to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::ACCEPTED); + assert_eq!(kernel.submit_calls.load(Ordering::SeqCst), 1); + let last = kernel.last_submit.lock().unwrap(); + let req = last.as_ref().expect("submit captured"); + assert_eq!(req.output_templates.len(), 1); + let cred = req.output_templates[0] + .delivery + .as_ref() + .expect("delivery present on proto"); + let inv = match cred.body.as_ref().expect("oneof") { + crate::kernel::kernel_v1::delivery_credential::Body::Invoice(i) => i, + other => panic!("expected Invoice, got {other:?}"), + }; + assert_eq!( + inv.pk0, + crate::hexutil::decode_hex_exact(&delivery_test_pk0(), 32).unwrap() + ); + assert_eq!(inv.memo, delivery_test_memo()); + assert_eq!(inv.relays, vec!["wss://relay.example".to_string()]); + // Position binding: sole template is index 0. + assert_eq!(req.output_templates[0].amount, "100"); + } + + /// Unknown `delivery.type` is API-edge 400 — kernel is never called. + #[tokio::test] + async fn post_tx_unknown_delivery_type_no_kernel_call() { + let kernel = Arc::new(ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let mut body = mint_body(); + body["output_templates"][0]["delivery"] = serde_json::json!({ + "type": "carrier_pigeon", + "invoice": { "amount": "1" } + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + assert_eq!( + kernel.submit_calls.load(Ordering::SeqCst), + 0, + "form rejection must not call SubmitTransition" + ); + } + + /// Unknown nested invoice field is API-edge 400 — no kernel call. + #[tokio::test] + async fn post_tx_unknown_invoice_field_no_kernel_call() { + let kernel = Arc::new(ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let mut body = mint_body_with_invoice_delivery(); + body["output_templates"][0]["delivery"]["invoice"]["ghost"] = Value::Bool(true); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + assert_eq!(kernel.submit_calls.load(Ordering::SeqCst), 0); + } + + /// Missing required invoice field is API-edge 400 — no kernel call. + #[tokio::test] + async fn post_tx_missing_invoice_pk0_no_kernel_call() { + let kernel = Arc::new(ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let mut body = mint_body_with_invoice_delivery(); + body["output_templates"][0]["delivery"]["invoice"] + .as_object_mut() + .unwrap() + .remove("pk0"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + // Response must not echo a pk0 that was never in the body either — + // and must not leak the memo that *was* present. + let msg = json["message"].as_str().unwrap_or(""); + assert!(!msg.contains(&delivery_test_memo())); + assert!(!msg.contains(&delivery_test_pk0())); + assert_eq!(kernel.submit_calls.load(Ordering::SeqCst), 0); + } + + /// Submit with a credential: 400 form-error message (wrong pk0 width) + /// must contain neither the pk0 hex nor the memo text. + #[tokio::test] + async fn post_tx_delivery_form_error_does_not_leak_pk0_or_memo() { + let kernel = Arc::new(ScriptedKernel { + submit: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let mut body = mint_body_with_invoice_delivery(); + // Wrong width — triggers decode_hex_field form error after parse. + let bad_pk0 = "ab".repeat(20); // 40 chars + body["output_templates"][0]["delivery"]["invoice"]["pk0"] = Value::String(bad_pk0.clone()); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/tx") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + let msg = json["message"].as_str().unwrap_or(""); + assert!( + msg.contains("pk0"), + "message must name the field path, got {msg}" + ); + assert!( + !msg.contains(&bad_pk0), + "§7.5 retention: must not echo pk0 hex, got {msg}" + ); + assert!( + !msg.contains(&delivery_test_memo()), + "§7.5 retention: must not echo memo, got {msg}" + ); + assert_eq!(kernel.submit_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn get_job_happy_path() { + let kernel = ScriptedKernel { + get: Some(Ok(accepted_job("job-2"))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/jobs/job-2") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()), + Some("2"), + "non-terminal poll must carry Retry-After" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["job_id"], "job-2"); + assert_eq!(json["status"], "accepted"); + assert_eq!(json["kind"], "mint"); + } + + #[tokio::test] + async fn get_job_not_found_is_404() { + // error_contract: JobNotFound → job_not_found / 404. + let status = + encode_kernel_error_status(Code::NotFound, "Job not found", "job_not_found", 404); + let kernel = ScriptedKernel { + get: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/jobs/missing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "job_not_found"); + } + + #[tokio::test] + async fn post_sign_wrong_phase_is_409() { + // error_contract: WrongPhase → wrong_phase / 409. + let status = encode_kernel_error_status( + Code::FailedPrecondition, + "not awaiting signature", + "wrong_phase", + 409, + ); + let kernel = ScriptedKernel { + sign: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let body = serde_json::json!({ + "signature": crate::hexutil::encode_hex(&[0u8; 64]), + "s2c_nonce": hex32(0xab), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/jobs/job-3/sign") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::CONFLICT); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "wrong_phase"); + } + + #[tokio::test] + async fn post_sign_happy_path() { + let mut job = accepted_job("job-3"); + job.status = "proving".to_string(); + let kernel = ScriptedKernel { + sign: Some(Ok(job)), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let body = serde_json::json!({ + "signature": crate::hexutil::encode_hex(&[1u8; 64]), + "s2c_nonce": hex32(0xcd), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/jobs/job-3/sign") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["job_id"], "job-3"); + assert_eq!(json["status"], "proving"); + } + + #[tokio::test] + async fn post_cancel_happy_path() { + let mut job = accepted_job("job-4"); + job.status = "cancelled".to_string(); + job.error = Some(crate::kernel::kernel_v1::JobError { + error: "proving_failed".into(), + message: "cancelled by client".into(), + }); + let kernel = ScriptedKernel { + cancel: Some(Ok(job)), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/jobs/job-4/cancel") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["status"], "cancelled"); + assert_eq!(json["error"]["error"], "proving_failed"); + } + + #[tokio::test] + async fn get_job_internal_error_message_is_neutral() { + const SECRET: &str = "enqueue failed: /var/lib/SECRET_JOB_PATH_xyz"; + let mut job = accepted_job("job-leak-poll"); + job.status = "failed".to_string(); + job.error = Some(crate::kernel::kernel_v1::JobError { + error: "internal_error".into(), + message: SECRET.into(), + }); + let kernel = ScriptedKernel { + get: Some(Ok(job)), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/jobs/job-leak-poll") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let bytes = body_bytes(res).await; + let text = String::from_utf8(bytes.clone()).unwrap(); + assert!( + !text.contains("SECRET_JOB_PATH"), + "poll body must not leak operator path: {text}" + ); + assert!(!text.contains("enqueue failed")); + let json: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["error"]["error"], "internal_error"); + assert_eq!( + json["error"]["message"], + crate::error::PUBLIC_INTERNAL_MESSAGE + ); + } + + #[tokio::test] + async fn stream_job_internal_error_message_is_neutral() { + const SECRET: &str = "enqueue failed: /var/lib/SECRET_SSE_PATH_xyz"; + let err_ev = JobEvent { + event: "error".into(), + job: Some(Job { + job_id: "job-leak-sse".into(), + kind: "mint".into(), + status: "failed".into(), + phase: String::new(), + progress: 1.0, + awaiting_signature: None, + result: None, + error: Some(crate::kernel::kernel_v1::JobError { + error: "internal_error".into(), + message: SECRET.into(), + }), + }), + }; + let kernel = ScriptedKernel { + stream: Some(Ok(vec![Ok(err_ev)])), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/jobs/job-leak-sse/stream") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = String::from_utf8(body_bytes(res).await).expect("utf8"); + assert!( + body.contains("event: error"), + "must emit error event, body={body}" + ); + assert!( + body.contains(crate::error::PUBLIC_INTERNAL_MESSAGE), + "SSE must carry neutral internal message, body={body}" + ); + assert!( + !body.contains("SECRET_SSE_PATH"), + "SSE must not leak operator path, body={body}" + ); + assert!(!body.contains("enqueue failed")); + } + + #[tokio::test] + async fn get_job_terminal_nonempty_phase_is_500() { + let mut job = accepted_job("job-phase"); + job.status = "completed".to_string(); + job.phase = "publishing".to_string(); + job.result = Some(crate::kernel::kernel_v1::JobResult { + new_account_state_hash: vec![0x11; 32], + output_coins_root: vec![0x22; 32], + input_nullifiers_root: vec![0x33; 32], + output_coin_ids: vec![], + publisher_pubkey: vec![], + attestation: vec![], + }); + let kernel = ScriptedKernel { + get: Some(Ok(job)), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/jobs/job-phase") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "internal_error"); + assert_eq!(json["message"], crate::error::PUBLIC_INTERNAL_MESSAGE); + } + + #[tokio::test] + async fn stream_job_emits_phase_then_complete() { + let phase = JobEvent { + event: "phase".into(), + job: Some(Job { + job_id: "job-5".into(), + kind: "mint".into(), + status: "proving".into(), + phase: "witness_build".into(), + progress: 0.25, + awaiting_signature: None, + result: None, + error: None, + }), + }; + let complete = JobEvent { + event: "complete".into(), + job: Some(Job { + job_id: "job-5".into(), + kind: "mint".into(), + status: "completed".into(), + phase: String::new(), + progress: 1.0, + awaiting_signature: None, + result: Some(crate::kernel::kernel_v1::JobResult { + new_account_state_hash: vec![0x11; 32], + output_coins_root: vec![0x22; 32], + input_nullifiers_root: vec![0x33; 32], + output_coin_ids: vec![vec![0x44; 32]], + publisher_pubkey: Vec::new(), + attestation: Vec::new(), + }), + error: None, + }), + }; + let kernel = ScriptedKernel { + stream: Some(Ok(vec![Ok(phase), Ok(complete)])), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/jobs/job-5/stream") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let ct = match res.headers().get("content-type") { + Some(v) => match v.to_str() { + Ok(s) => s, + Err(e) => panic!("content-type is not ASCII: {e}"), + }, + None => panic!("SSE response missing content-type header"), + }; + assert!( + ct.starts_with("text/event-stream"), + "SSE content-type, got {ct:?}" + ); + let body = String::from_utf8(body_bytes(res).await).expect("utf8"); + assert!( + body.contains("event: phase"), + "must emit phase event, body={body}" + ); + assert!( + body.contains("event: complete"), + "must emit complete event, body={body}" + ); + assert!( + body.contains("\"status\":\"proving\""), + "phase data must carry status, body={body}" + ); + assert!( + body.contains("\"status\":\"completed\""), + "complete data must carry completed status, body={body}" + ); + } + + #[tokio::test] + async fn stream_job_break_emits_error_event() { + let kernel = ScriptedKernel { + stream: Some(Ok(vec![Err(ApiError::internal("kernel stream dropped"))])), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/jobs/job-6/stream") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let body = String::from_utf8(body_bytes(res).await).expect("utf8"); + assert!( + body.contains("event: error"), + "broken stream must emit error event, body={body}" + ); + assert!( + body.contains("internal_error"), + "error event must carry machine code, body={body}" + ); + assert!( + body.contains(crate::error::PUBLIC_INTERNAL_MESSAGE), + "error event must carry the public internal message, body={body}" + ); + assert!( + !body.contains("kernel stream dropped"), + "error event must not leak the operator cause, body={body}" + ); + } + + #[tokio::test] + async fn stream_job_not_found_before_sse() { + let status = + encode_kernel_error_status(Code::NotFound, "Job not found", "job_not_found", 404); + let kernel = ScriptedKernel { + stream: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/jobs/missing/stream") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::NOT_FOUND); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "job_not_found"); + } + + // ----------------------------------------------------------------------- + // Info / readiness / chain read surface + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn get_info_happy_path() { + let kernel = ScriptedKernel { + info: Some(Ok(sample_info(true, None))), + ..Default::default() + }; + let mut features = BTreeSet::new(); + features.insert(Feature::Wallet); + let cfg = Config { + bind_addr: "127.0.0.1:0".parse().unwrap(), + kernel_addr: "http://127.0.0.1:50051".to_string(), + features, + public_hosts: vec!["node.example.com".to_string()], + blossom: None, + }; + let app = build_router(cfg, Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/info") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["network"], "regtest"); + assert_eq!(json["protocol_version"], "v1"); + assert_eq!(json["finality_confirmations"], 6); + assert_eq!(json["max_tx_inputs"], 8); + assert_eq!(json["features"], serde_json::json!(["wallet"])); + assert_eq!( + json["bootstrap_pubkey"].as_str().unwrap().len(), + 64, + "bootstrap_pubkey is hex32" + ); + assert_eq!(json["bootstrap"]["network"], "regtest"); + // Kernel-only fields must not leak onto the public surface. + assert!(json.get("ready").is_none()); + assert!(json.get("kernel_parts").is_none()); + assert!(json.get("accumulator_root").is_none()); + } + + #[tokio::test] + async fn get_info_kernel_internal_is_500() { + let status = encode_kernel_error_status( + Code::Internal, + "Chain identity unavailable", + "internal_error", + 500, + ); + let kernel = ScriptedKernel { + info: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/info") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "internal_error"); + assert_eq!( + json["message"], + crate::error::PUBLIC_INTERNAL_MESSAGE, + "public message must be neutral, not the kernel diagnostic" + ); + assert!( + !json["message"] + .as_str() + .unwrap() + .contains("Chain identity unavailable"), + "kernel diagnostic must not appear on the wire" + ); + } + + #[tokio::test] + async fn health_ready_true_is_200() { + let kernel = ScriptedKernel { + info: Some(Ok(sample_info(true, None))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/health/ready") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["ready"], true); + assert!( + json.get("reason").is_none(), + "ready:true must not carry reason" + ); + // Diagnostics from GetInfo (MAY); root/size are not unpaired here. + assert_eq!(json["bitcoin_tip_height"], 100); + assert_eq!(json["scanner_lag"], 0); + assert!(json.get("root").is_none()); + // Must not use the generic error body shape. + assert!(json.get("error").is_none()); + } + + #[tokio::test] + async fn health_ready_false_is_503_with_reason() { + let kernel = ScriptedKernel { + info: Some(Ok(sample_info(false, Some("syncing")))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/health/ready") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::SERVICE_UNAVAILABLE); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["ready"], false); + assert_eq!(json["reason"], "syncing"); + assert!(json.get("error").is_none()); + } + + /// Fail-closed production posture: node `GetInfo` returns Internal when + /// `ChainIdentity` is unset. The readiness probe must answer **not ready** + /// (503 + dependency_unavailable), never invent `ready: true`. + #[tokio::test] + async fn health_ready_getinfo_failure_is_not_ready_dependency_unavailable() { + let status = encode_kernel_error_status( + Code::Internal, + "Chain identity unavailable", + "internal_error", + 500, + ); + let kernel = ScriptedKernel { + info: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/health/ready") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::SERVICE_UNAVAILABLE, + "failed GetInfo must not green-light readiness" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["ready"], false); + assert_eq!(json["reason"], "dependency_unavailable"); + // Readiness shape, not the generic §7.5 error body. + assert!( + json.get("error").is_none(), + "must not use generic error body on /health/ready" + ); + } + + #[tokio::test] + async fn chain_accumulator_happy_path() { + let kernel = ScriptedKernel { + accumulator: Some(Ok(AccumulatorTip { + root: vec![0xAB; 32], + tip_block_hash: vec![0xCD; 32], + tip_height: 42, + size: 7, + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/chain/accumulator") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["size"], 7); + assert_eq!(json["tip_height"], 42); + assert_eq!( + json["root"].as_str().unwrap(), + crate::hexutil::encode_hex(&[0xAB; 32]) + ); + assert_eq!( + json["tip_block_hash"].as_str().unwrap(), + crate::hexutil::encode_hex(&[0xCD; 32]) + ); + } + + #[tokio::test] + async fn chain_accumulator_kernel_error_uses_error_info() { + let status = encode_kernel_error_status( + Code::Internal, + "Chain view unavailable", + "internal_error", + 500, + ); + let kernel = ScriptedKernel { + accumulator: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/chain/accumulator") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "internal_error"); + assert_eq!( + json["message"], + crate::error::PUBLIC_INTERNAL_MESSAGE, + "internal_error must carry the neutral public message, got {}", + json["message"] + ); + assert!( + !json["message"] + .as_str() + .unwrap() + .contains("Chain view unavailable"), + "kernel cause must stay off the wire" + ); + } + + #[tokio::test] + async fn chain_nullifier_present_happy_path() { + let kernel = ScriptedKernel { + nullifier_path: Some(Ok(NullifierPath { + root: vec![0x01; 32], + tip_height: 10, + present: true, + leaf: vec![0x02; 32], + position: 3, + audit_path: vec![vec![0x03; 32], vec![0x04; 32]], + tree_size: 4, + tip_block_hash: vec![0x05; 32], + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let pk = hex32(0xaa); + let res = app + .oneshot( + Request::builder() + .uri(format!("/v1/chain/nullifier/{pk}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["present"], true); + assert_eq!(json["position"], 3); + assert_eq!( + json["leaf"].as_str().unwrap(), + crate::hexutil::encode_hex(&[0x02; 32]) + ); + assert_eq!(json["audit_path"].as_array().unwrap().len(), 2); + assert_eq!(json["tree_size"], 4); + assert_eq!(json["tip_height"], 10); + } + + #[tokio::test] + async fn chain_nullifier_absent_omits_position_and_leaf() { + let kernel = ScriptedKernel { + nullifier_path: Some(Ok(NullifierPath { + root: vec![0x01; 32], + tip_height: 10, + present: false, + leaf: Vec::new(), + position: 0, + audit_path: Vec::new(), + tree_size: 4, + tip_block_hash: vec![0x05; 32], + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let pk = hex32(0xbb); + let res = app + .oneshot( + Request::builder() + .uri(format!("/v1/chain/nullifier/{pk}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["present"], false); + assert!( + json.get("position").is_none(), + "absent must omit position, got {json}" + ); + assert!( + json.get("leaf").is_none(), + "absent must omit leaf, got {json}" + ); + assert_eq!(json["audit_path"], serde_json::json!([])); + assert_eq!(json["tree_size"], 4); + assert_eq!( + json["root"].as_str().unwrap(), + crate::hexutil::encode_hex(&[0x01; 32]) + ); + } + + /// The decisive case: a corrupt index is kernel `internal_error`, not + /// `present: false`. The api must not flatten that distinction. + #[tokio::test] + async fn chain_nullifier_kernel_internal_is_not_absent() { + let status = encode_kernel_error_status( + Code::Internal, + "Failed to build nullifier path", + "internal_error", + 500, + ); + let kernel = ScriptedKernel { + nullifier_path: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let pk = hex32(0xcc); + let res = app + .oneshot( + Request::builder() + .uri(format!("/v1/chain/nullifier/{pk}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!( + json["error"], "internal_error", + "corrupt index must surface as ErrorInfo, not as present:false" + ); + assert!( + json.get("present").is_none(), + "error body must not look like a Path-B absence answer" + ); + assert_eq!( + json["message"], + crate::error::PUBLIC_INTERNAL_MESSAGE, + "internal_error must carry the neutral public message, got {}", + json["message"] + ); + assert!( + !json["message"] + .as_str() + .unwrap() + .contains("Failed to build nullifier path"), + "kernel cause must stay off the wire" + ); + } + + #[tokio::test] + async fn chain_nullifier_malformed_pubkey_is_400() { + let kernel = ScriptedKernel { + nullifier_path: Some(Ok(NullifierPath { + root: vec![0x01; 32], + tip_height: 0, + present: false, + leaf: Vec::new(), + position: 0, + audit_path: Vec::new(), + tree_size: 0, + tip_block_hash: vec![0x05; 32], + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/chain/nullifier/not-hex") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + assert!( + json["message"].as_str().unwrap().contains("pubkey"), + "message must name pubkey, got {}", + json["message"] + ); + } + + // ----------------------------------------------------------------------- + // Stage C1 — OwnershipProof gate (attest / grants) + // ----------------------------------------------------------------------- + + use crate::hexutil::encode_hex; + use crate::ownership::{ + attest_request_hash, ceiling_encoding, chan_bind_for_host, encode_grant_asset_ids, + encode_zk_address, issue_grant_request_hash, ownership_challenge_message, ChallengeDomain, + ATTEST_BALANCE_CHALLENGE_DOMAIN, ISSUE_GRANT_CHALLENGE_DOMAIN, SCOPE_NOT_AFTER_UNBOUNDED, + }; + use bitcoin::secp256k1::{Keypair, Message, Secp256k1, SecretKey}; + + /// Expose address helper for tests via a thin re-export path. + /// (`address_from_pk0_nk_commit` is private; tests use the public + /// ownership helpers that already cover the same path.) + mod ownership_fixtures { + use super::*; + + pub fn sample_sk_pk() -> (SecretKey, [u8; 32]) { + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x42u8; 32]).expect("32-byte secret"); + let kp = Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = kp.x_only_public_key(); + (sk, xonly.serialize()) + } + + pub fn sign_chal(sk: &SecretKey, chal: &[u8; 32]) -> [u8; 64] { + let secp = Secp256k1::new(); + let kp = Keypair::from_secret_key(&secp, sk); + let msg = Message::from_digest_slice(chal).expect("32-byte digest"); + let sig = secp.sign_schnorr_no_aux_rand(&msg, &kp); + let mut out = [0u8; 64]; + out.copy_from_slice(sig.as_ref()); + out + } + + pub fn identity() -> (SecretKey, [u8; 32], [u8; 32], [u8; 32], String) { + let (sk, pk0) = sample_sk_pk(); + let nk_commit = [0u8; 32]; + // H(Pk0 ‖ nk_commit) with zero digest — same as ownership unit tests. + let mut pre = [0u8; 64]; + pre[..32].copy_from_slice(&pk0); + pre[32..].copy_from_slice(&nk_commit); + let subject_raw: [u8; 32] = { + use sha2::{Digest, Sha256}; + Sha256::digest(pre).into() + }; + let subject_bech = encode_zk_address(&subject_raw); + (sk, pk0, nk_commit, subject_raw, subject_bech) + } + } + + fn ownership_proof_json( + subject: &str, + pk0: &[u8; 32], + nkc: &[u8; 32], + sig: &[u8; 64], + ) -> Value { + serde_json::json!({ + "type": "ownership", + "subject": subject, + "public_key": encode_hex(pk0), + "nk_commit": encode_hex(nkc), + "signature": encode_hex(sig), + }) + } + + #[tokio::test] + async fn attest_balance_valid_ownership_calls_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x11u8; 32]; + let expiry = 1_700_000_060u64; + let asset = [0x22u8; 32]; + let ceiling_enc = ceiling_encoding(None, None).unwrap(); + let request_hash = attest_request_hash(&subject_raw, &asset, &ceiling_enc); + let cb = chan_bind_for_host(host); + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + let kernel = Arc::new(ScriptedKernel { + attest: Some(Ok(JobHandle { + job_id: "attest-job-1".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "asset_id": encode_hex(&asset), + "challenge": { + "nonce": encode_hex(&nonce), + "expiry": expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(&subject_bech, &pk0, &nkc, &sig), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/balance") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::ACCEPTED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["job_id"], "attest-job-1"); + assert_eq!(kernel.attest_calls.load(Ordering::SeqCst), 1); + } + + /// Without the status gate, any non-empty job_id would be admitted as 202 + /// even when JobHandle.status is not `"accepted"`. + #[tokio::test] + async fn attest_balance_non_accepted_status_is_500() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x11u8; 32]; + let expiry = 1_700_000_060u64; + let asset = [0x22u8; 32]; + let ceiling_enc = ceiling_encoding(None, None).unwrap(); + let request_hash = attest_request_hash(&subject_raw, &asset, &ceiling_enc); + let cb = chan_bind_for_host(host); + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + let kernel = Arc::new(ScriptedKernel { + attest: Some(Ok(JobHandle { + job_id: "attest-job-bad".into(), + status: "proving".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "asset_id": encode_hex(&asset), + "challenge": { + "nonce": encode_hex(&nonce), + "expiry": expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(&subject_bech, &pk0, &nkc, &sig), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/balance") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "internal_error"); + assert_eq!(json["message"], crate::error::PUBLIC_INTERNAL_MESSAGE); + assert_eq!(kernel.attest_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn attest_balance_bad_signature_does_not_call_kernel() { + let host = "node.example.com"; + let (_sk, pk0, nkc, _subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x11u8; 32]; + let expiry = 1_700_000_060u64; + let asset = [0x22u8; 32]; + let bad_sig = [0xFFu8; 64]; + + let kernel = Arc::new(ScriptedKernel { + attest: Some(Ok(JobHandle { + job_id: "should-not-run".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "asset_id": encode_hex(&asset), + "challenge": { + "nonce": encode_hex(&nonce), + "expiry": expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(&subject_bech, &pk0, &nkc, &bad_sig), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/balance") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!( + kernel.attest_calls.load(Ordering::SeqCst), + 0, + "failed signature must not reach AttestBalance (nonce not consumed)" + ); + let _ = host; // documents the host used by test_config + } + + /// Domain separation in both directions — the most important test of C1. + #[tokio::test] + async fn domain_separation_attest_signed_proof_does_not_authorise_grants() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x33u8; 32]; + let expiry = 1_700_000_060u64; + let grantee = [0x44u8; 32]; + let grant_expiry = 2_000_000_000u64; + let asset_enc = encode_grant_asset_ids(true, &[]).unwrap(); + let request_hash = issue_grant_request_hash( + &subject_raw, + &grantee, + &asset_enc, + 0, + SCOPE_NOT_AFTER_UNBOUNDED, + grant_expiry, + ); + let cb = chan_bind_for_host(host); + // Sign under **AttestBalance** domain (wrong for /v1/grants). + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + let kernel = Arc::new(ScriptedKernel { + issue_grant: Some(Ok(GrantResult { + grant: "zkgrant1qqqq".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "grantee_pk": encode_hex(&grantee), + "scope": { "asset_ids": "*" }, + "expiry": grant_expiry.to_string(), + "challenge": { + "nonce": encode_hex(&nonce), + "expiry": expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(&subject_bech, &pk0, &nkc, &sig), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/grants") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!( + kernel.issue_grant_calls.load(Ordering::SeqCst), + 0, + "attest-domain proof must not call IssueViewGrant" + ); + } + + #[tokio::test] + async fn domain_separation_grant_signed_proof_does_not_authorise_attest() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x55u8; 32]; + let expiry = 1_700_000_060u64; + let asset = [0x66u8; 32]; + let ceiling_enc = ceiling_encoding(None, None).unwrap(); + let request_hash = attest_request_hash(&subject_raw, &asset, &ceiling_enc); + let cb = chan_bind_for_host(host); + // Sign under **IssueGrant** domain (wrong for /v1/attest/balance). + let chal = ownership_challenge_message( + ChallengeDomain::IssueGrant.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + let kernel = Arc::new(ScriptedKernel { + attest: Some(Ok(JobHandle { + job_id: "nope".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "asset_id": encode_hex(&asset), + "challenge": { + "nonce": encode_hex(&nonce), + "expiry": expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(&subject_bech, &pk0, &nkc, &sig), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/balance") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(kernel.attest_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn wrong_chan_bind_rejects_without_kernel() { + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let signed_host = "other.example.com"; + let nonce = [0x77u8; 32]; + let expiry = 99u64; + let asset = [0x88u8; 32]; + let ceiling_enc = ceiling_encoding(None, None).unwrap(); + let request_hash = attest_request_hash(&subject_raw, &asset, &ceiling_enc); + let cb = chan_bind_for_host(signed_host); + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + let kernel = Arc::new(ScriptedKernel { + attest: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + // test_config serves node.example.com — signature bound to other host. + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "asset_id": encode_hex(&asset), + "challenge": { + "nonce": encode_hex(&nonce), + "expiry": expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(&subject_bech, &pk0, &nkc, &sig), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/balance") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(kernel.attest_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn wrong_request_hash_rejects_without_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x99u8; 32]; + let expiry = 100u64; + let asset_signed = [0xAAu8; 32]; + let asset_presented = [0xBBu8; 32]; + let ceiling_enc = ceiling_encoding(None, None).unwrap(); + let request_hash = attest_request_hash(&subject_raw, &asset_signed, &ceiling_enc); + let cb = chan_bind_for_host(host); + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + let kernel = Arc::new(ScriptedKernel { + attest: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "asset_id": encode_hex(&asset_presented), + "challenge": { + "nonce": encode_hex(&nonce), + "expiry": expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(&subject_bech, &pk0, &nkc, &sig), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/balance") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(kernel.attest_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn expired_challenge_is_passthrough_from_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0xCCu8; 32]; + let expiry = 1_700_000_060u64; + let asset = [0xDDu8; 32]; + let ceiling_enc = ceiling_encoding(None, None).unwrap(); + let request_hash = attest_request_hash(&subject_raw, &asset, &ceiling_enc); + let cb = chan_bind_for_host(host); + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + // Signature is valid; kernel reports challenge_expired via ErrorInfo + // (gRPC UNAUTHENTICATED + http_status 410 — production triple). + let expired = encode_kernel_error_status( + tonic::Code::Unauthenticated, + "challenge nonce expired", + "challenge_expired", + 410, + ); + let kernel = Arc::new(ScriptedKernel { + attest: Some(Err(crate::kernel::kernel_status_to_api_error(&expired))), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "asset_id": encode_hex(&asset), + "challenge": { + "nonce": encode_hex(&nonce), + "expiry": expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(&subject_bech, &pk0, &nkc, &sig), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/balance") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::GONE); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "challenge_expired"); + assert_eq!(kernel.attest_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn grant_proof_type_is_unauthorized_without_kernel() { + // Real GrantProof wire shape (no ownership fields). Must deserialise + // as the grant arm and answer 401 — not 400 from missing subject/pk. + let (_sk, _pk0, _nkc, _subject_raw, subject_bech) = ownership_fixtures::identity(); + let kernel = Arc::new(ScriptedKernel { + attest: Some(Ok(JobHandle { + job_id: "x".into(), + status: "accepted".into(), + })), + issue_grant: Some(Ok(GrantResult { + grant: "zkgrant1".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "asset_id": encode_hex(&[0u8; 32]), + "challenge": { + "nonce": encode_hex(&[1u8; 32]), + "expiry": "100", + }, + "ownership_proof": { + "type": "grant", + "grant": "zkgrant1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "grantee_pk": encode_hex(&[0xABu8; 32]), + "signature": encode_hex(&[0u8; 64]), + }, + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/balance") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "real GrantProof form must be 401, not 400 malformed" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert!( + json["message"].as_str().unwrap().contains("GrantProof"), + "message must name GrantProof, got {}", + json["message"] + ); + assert_eq!(kernel.attest_calls.load(Ordering::SeqCst), 0); + assert_eq!(kernel.issue_grant_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn grants_real_grant_proof_form_is_401_without_kernel() { + let (_sk, _pk0, _nkc, _subject_raw, subject_bech) = ownership_fixtures::identity(); + let kernel = Arc::new(ScriptedKernel { + issue_grant: Some(Ok(GrantResult { + grant: "zkgrant1".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "grantee_pk": encode_hex(&[0xFFu8; 32]), + "scope": { "asset_ids": "*" }, + "expiry": "2000000000", + "challenge": { + "nonce": encode_hex(&[2u8; 32]), + "expiry": "100", + }, + "ownership_proof": { + "type": "grant", + "grant": "zkgrant1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq", + "grantee_pk": encode_hex(&[0xABu8; 32]), + "signature": encode_hex(&[0u8; 64]), + }, + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/grants") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!(kernel.issue_grant_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn grants_valid_ownership_calls_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0xEEu8; 32]; + let challenge_expiry = 1_700_000_060u64; + let grantee = [0xFFu8; 32]; + let grant_expiry = 2_000_000_000u64; + let asset_enc = encode_grant_asset_ids(true, &[]).unwrap(); + let request_hash = issue_grant_request_hash( + &subject_raw, + &grantee, + &asset_enc, + 0, + SCOPE_NOT_AFTER_UNBOUNDED, + grant_expiry, + ); + let cb = chan_bind_for_host(host); + let chal = ownership_challenge_message( + ChallengeDomain::IssueGrant.as_str(), + &nonce, + &cb, + &subject_raw, + challenge_expiry, + &request_hash, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + let kernel = Arc::new(ScriptedKernel { + issue_grant: Some(Ok(GrantResult { + grant: "zkgrant1qpvalid".into(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "subject": subject_bech, + "grantee_pk": encode_hex(&grantee), + "scope": { "asset_ids": "*" }, + "expiry": grant_expiry.to_string(), + "challenge": { + "nonce": encode_hex(&nonce), + "expiry": challenge_expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(&subject_bech, &pk0, &nkc, &sig), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/grants") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["grant"], "zkgrant1qpvalid"); + assert_eq!(kernel.issue_grant_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn challenge_endpoints_return_endpoint_domain() { + let (_, _, _, _, subject_bech) = ownership_fixtures::identity(); + let kernel = Arc::new(ScriptedKernel { + open_challenge: Some(Ok(Challenge { + nonce: vec![0xABu8; 32], + expiry: 1_700_000_060, + domain: ATTEST_BALANCE_CHALLENGE_DOMAIN.to_string(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/attest/balance/challenge") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ "subject": subject_bech }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["domain"], ATTEST_BALANCE_CHALLENGE_DOMAIN); + assert_eq!(json["expiry"], "1700000060"); + assert_eq!(json["nonce"].as_str().unwrap().len(), 64); + assert_eq!(kernel.open_challenge_calls.load(Ordering::SeqCst), 1); + + let kernel2 = Arc::new(ScriptedKernel { + open_challenge: Some(Ok(Challenge { + nonce: vec![0xCDu8; 32], + expiry: 1_700_000_120, + domain: ISSUE_GRANT_CHALLENGE_DOMAIN.to_string(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel2).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/grants/challenge") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ "subject": subject_bech }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["domain"], ISSUE_GRANT_CHALLENGE_DOMAIN); + } + + // ----------------------------------------------------------------------- + // Stage C2 — Pull / Record / Proof / AccountState + // ----------------------------------------------------------------------- + + use crate::kernel::kernel_v1::RecordRef; + use crate::ownership::{pull_challenge_message, PULL_CHALLENGE_DOMAIN}; + + fn sample_pull_result() -> ProtoPullResult { + ProtoPullResult { + records: vec![RecordRef { + record_id: vec![0x11u8; 32], + record_type: "coinproof".into(), + transition_kind: String::new(), + blob_id: vec![0x22u8; 32], + occurred_at: 1_700_000_000, + }], + session: "sess-token-1".into(), + session_expiry: 1_700_000_300, + } + } + + fn pull_body_ownership( + subject: &str, + pk0: &[u8; 32], + nkc: &[u8; 32], + nonce: &[u8; 32], + expiry: u64, + sig: &[u8; 64], + ) -> Value { + serde_json::json!({ + "nonce": encode_hex(nonce), + "expiry": expiry.to_string(), + "proof": { + "type": "ownership", + "subject": subject, + "public_key": encode_hex(pk0), + "nk_commit": encode_hex(nkc), + "signature": encode_hex(sig), + } + }) + } + + #[tokio::test] + async fn pull_challenge_returns_pull_domain() { + let (_, _, _, _, subject_bech) = ownership_fixtures::identity(); + let kernel = Arc::new(ScriptedKernel { + open_challenge: Some(Ok(Challenge { + nonce: vec![0xABu8; 32], + expiry: 1_700_000_060, + domain: PULL_CHALLENGE_DOMAIN.to_string(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull/challenge") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ "subject": subject_bech }).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["domain"], PULL_CHALLENGE_DOMAIN); + assert_eq!(json["expiry"], "1700000060"); + assert_eq!(json["nonce"].as_str().unwrap().len(), 64); + assert_eq!(kernel.open_challenge_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn pull_valid_ownership_opens_session_with_ownership_authority() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x11u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Pull.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(sample_pull_result())), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from( + pull_body_ownership(&subject_bech, &pk0, &nkc, &nonce, expiry, &sig) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["session"], "sess-token-1"); + assert_eq!(json["session_expiry"], "1700000300"); + assert_eq!(json["records"][0]["record_type"], "coinproof"); + assert_eq!(json["records"][0]["occurred_at"], "1700000000"); + assert!( + json["records"][0].get("transition_kind").is_none(), + "coinproof without transition_kind must omit the field" + ); + assert_eq!(kernel.pull_calls.load(Ordering::SeqCst), 1); + assert_eq!( + *kernel.last_pull_authority.lock().unwrap(), + Some(SessionAuthority::Ownership), + "session authority must follow the OwnershipProof kind" + ); + } + + #[tokio::test] + async fn pull_grant_without_published_op_is_rejected_without_kernel_call() { + // Without a published op_pubkey for the subject (empty subject_ops / + // no Nostr profile resolution) GrantProof fails at §5.1(b) step 1 — + // never half-checked, never a kernel call. Uses a structurally valid, + // op-signed zkgrant whose subject is deliberately absent from + // subject_ops so the missing-op arm is the one that fires. + use crate::ownership::{ + encode_grant_asset_ids, encode_view_grant, grant_message_digest, ResolvedScope, + GRANT_VERSION, + }; + use bitcoin::secp256k1::{Keypair, Message, Secp256k1, SecretKey}; + + let secp = Secp256k1::new(); + let op_sk = SecretKey::from_slice(&[0x55u8; 32]).unwrap(); + let op_kp = Keypair::from_secret_key(&secp, &op_sk); + let grantee_sk = SecretKey::from_slice(&[0x66u8; 32]).unwrap(); + let grantee_kp = Keypair::from_secret_key(&secp, &grantee_sk); + let (grantee_xonly, _) = grantee_kp.x_only_public_key(); + let grantee_pk = grantee_xonly.serialize(); + let subject = [0x10u8; 32]; + let grant_scope = ResolvedScope { + all_assets: false, + asset_ids: vec![[0x01u8; 32]], + not_before: 100, + not_after: 9_000_000_000, + }; + let grant_expiry = 4_000_000_000u64; + let grant_nonce = [0x77u8; 16]; + let asset_enc = + encode_grant_asset_ids(grant_scope.all_assets, &grant_scope.asset_ids).unwrap(); + let (grant_message, _) = grant_message_digest( + GRANT_VERSION, + &subject, + &grantee_pk, + &asset_enc, + grant_scope.not_before, + grant_scope.not_after, + grant_expiry, + &grant_nonce, + ); + let msg = Message::from_digest_slice(&grant_message).unwrap(); + let op_sig = secp.sign_schnorr_no_aux_rand(&msg, &op_kp); + let mut op_sig_bytes = [0u8; 64]; + op_sig_bytes.copy_from_slice(op_sig.as_ref()); + let grant_bech = encode_view_grant( + &subject, + &grantee_pk, + &grant_scope, + grant_expiry, + &grant_nonce, + &op_sig_bytes, + ) + .unwrap(); + + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(sample_pull_result())), + ..Default::default() + }); + // build_router installs an empty subject_ops — subject has no published op. + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "nonce": encode_hex(&[0x11u8; 32]), + "expiry": "1700000060", + "proof": { + "type": "grant", + "grant": grant_bech, + "grantee_pk": encode_hex(&grantee_pk), + "signature": encode_hex(&[0x44u8; 64]), + } + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert!( + json["message"].as_str().unwrap().contains("op_pubkey") + || json["message"].as_str().unwrap().contains("published"), + "message must name the missing published op check: {}", + json["message"] + ); + assert_eq!( + kernel.pull_calls.load(Ordering::SeqCst), + 0, + "rejected grant must not consume the challenge nonce" + ); + } + + #[tokio::test] + async fn pull_valid_grant_opens_session_with_grant_authority_and_clamped_scope() { + use crate::ownership::{ + encode_grant_asset_ids, encode_view_grant, grant_message_digest, ResolvedScope, + RevokedGrantSet, SubjectOpDirectory, GRANT_VERSION, SCOPE_NOT_AFTER_UNBOUNDED, + }; + use bitcoin::secp256k1::{Keypair, Message, Secp256k1, SecretKey}; + use sha2::{Digest, Sha256}; + + let host = "node.example.com"; + let secp = Secp256k1::new(); + let op_sk = SecretKey::from_slice(&[0x55u8; 32]).unwrap(); + let op_kp = Keypair::from_secret_key(&secp, &op_sk); + let (op_xonly, _) = op_kp.x_only_public_key(); + let op_pk = op_xonly.serialize(); + let grantee_sk = SecretKey::from_slice(&[0x66u8; 32]).unwrap(); + let grantee_kp = Keypair::from_secret_key(&secp, &grantee_sk); + let (grantee_xonly, _) = grantee_kp.x_only_public_key(); + let grantee_pk = grantee_xonly.serialize(); + let subject = [0x10u8; 32]; + let grant_scope = ResolvedScope { + all_assets: false, + asset_ids: vec![[0x01u8; 32]], + not_before: 100, + not_after: 9_000_000_000, + }; + // Expiry far in the future so wall-clock `unix_now` in the handler passes. + let grant_expiry = 4_000_000_000u64; + let grant_nonce = [0x77u8; 16]; + let asset_enc = + encode_grant_asset_ids(grant_scope.all_assets, &grant_scope.asset_ids).unwrap(); + let (grant_message, _) = grant_message_digest( + GRANT_VERSION, + &subject, + &grantee_pk, + &asset_enc, + grant_scope.not_before, + grant_scope.not_after, + grant_expiry, + &grant_nonce, + ); + let msg = Message::from_digest_slice(&grant_message).unwrap(); + let op_sig = secp.sign_schnorr_no_aux_rand(&msg, &op_kp); + let mut op_sig_bytes = [0u8; 64]; + op_sig_bytes.copy_from_slice(op_sig.as_ref()); + let grant_bech = encode_view_grant( + &subject, + &grantee_pk, + &grant_scope, + grant_expiry, + &grant_nonce, + &op_sig_bytes, + ) + .unwrap(); + + let challenge_nonce = [0x11u8; 32]; + let chal_expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let mut chal_pre = Vec::new(); + chal_pre.extend_from_slice(PULL_CHALLENGE_DOMAIN.as_bytes()); + chal_pre.extend_from_slice(&challenge_nonce); + chal_pre.extend_from_slice(&cb); + chal_pre.extend_from_slice(&subject); + chal_pre.extend_from_slice(&chal_expiry.to_be_bytes()); + let chal: [u8; 32] = Sha256::digest(&chal_pre).into(); + let chal_msg = Message::from_digest_slice(&chal).unwrap(); + let grantee_sig = secp.sign_schnorr_no_aux_rand(&chal_msg, &grantee_kp); + let mut grantee_sig_bytes = [0u8; 64]; + grantee_sig_bytes.copy_from_slice(grantee_sig.as_ref()); + + let subject_ops = Arc::new(SubjectOpDirectory::new()); + subject_ops.insert(subject, op_pk); + + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(sample_pull_result())), + ..Default::default() + }); + let config = test_config(); + let state = AppState { + kernel: kernel.clone(), + features: config.features.clone(), + public_hosts: Arc::new(config.public_hosts.clone()), + blossom: None, + subject_ops, + revoked_grants: Arc::new(RevokedGrantSet::new()), + }; + let app = { + let mut router = Router::new().route("/", get(root)); + for surface in ServedSurface::active(&config.features, false) { + router = surface.register(router, None); + } + router.with_state(state) + }; + + let body = serde_json::json!({ + "nonce": encode_hex(&challenge_nonce), + "expiry": chal_expiry.to_string(), + // Request wider than the grant → must clamp to grant scope. + "scope": { + "asset_ids": "*", + }, + "proof": { + "type": "grant", + "grant": grant_bech, + "grantee_pk": encode_hex(&grantee_pk), + "signature": encode_hex(&grantee_sig_bytes), + } + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = res.status(); + let resp_body = body_bytes(res).await; + assert_eq!( + status, + StatusCode::OK, + "body={}", + String::from_utf8_lossy(&resp_body) + ); + assert_eq!(kernel.pull_calls.load(Ordering::SeqCst), 1); + assert_eq!( + *kernel.last_pull_authority.lock().unwrap(), + Some(SessionAuthority::Grant) + ); + let last = kernel.last_pull.lock().unwrap().clone().expect("pull req"); + let scope = last.resolved_scope.expect("resolved_scope"); + assert!(!scope.all_assets, "grant session must not be all_assets=*"); + assert_eq!(scope.asset_ids, vec![vec![0x01u8; 32]]); + assert_eq!(scope.not_before, 100); + assert_eq!(scope.not_after, 9_000_000_000); + // Must not be the unbounded sentinel pair. + assert_ne!(scope.not_after, SCOPE_NOT_AFTER_UNBOUNDED); + } + + #[tokio::test] + async fn pull_ownership_passes_requested_scope_not_forced_unbounded() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x19u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Pull.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(sample_pull_result())), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let asset = [0xABu8; 32]; + let mut body = pull_body_ownership(&subject_bech, &pk0, &nkc, &nonce, expiry, &sig); + body["scope"] = serde_json::json!({ + "asset_ids": [encode_hex(&asset)], + "not_before": "10", + "not_after": "20", + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let last = kernel.last_pull.lock().unwrap().clone().expect("pull req"); + let scope = last.resolved_scope.expect("resolved_scope"); + assert!(!scope.all_assets); + assert_eq!(scope.asset_ids, vec![asset.to_vec()]); + assert_eq!(scope.not_before, 10); + assert_eq!(scope.not_after, 20); + } + + #[tokio::test] + async fn pull_bad_signature_does_not_call_kernel() { + let (_sk, pk0, nkc, _subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x11u8; 32]; + let expiry = 1_700_000_060u64; + let bad_sig = [0xFFu8; 64]; + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(sample_pull_result())), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from( + pull_body_ownership(&subject_bech, &pk0, &nkc, &nonce, expiry, &bad_sig) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(kernel.pull_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn pull_wrong_domain_signature_does_not_call_kernel() { + // Sign under AttestBalance domain, redeem under Pull → unauthorized. + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x22u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let request_hash = [0u8; 32]; + let chal = ownership_challenge_message( + ChallengeDomain::AttestBalance.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + &request_hash, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(sample_pull_result())), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from( + pull_body_ownership(&subject_bech, &pk0, &nkc, &nonce, expiry, &sig) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(kernel.pull_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn pull_wrong_chan_bind_does_not_call_kernel() { + let signed_host = "signed.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x33u8; 32]; + let expiry = 50u64; + let cb = chan_bind_for_host(signed_host); + let chal = pull_challenge_message( + ChallengeDomain::Pull.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + // test_config serves node.example.com — different chan_bind. + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(sample_pull_result())), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from( + pull_body_ownership(&subject_bech, &pk0, &nkc, &nonce, expiry, &sig) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(kernel.pull_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn pull_altered_expiry_does_not_call_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x44u8; 32]; + let signed_expiry = 100u64; + let presented_expiry = 999u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Pull.as_str(), + &nonce, + &cb, + &subject_raw, + signed_expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(sample_pull_result())), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from( + pull_body_ownership( + &subject_bech, + &pk0, + &nkc, + &nonce, + presented_expiry, + &sig, + ) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(kernel.pull_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn session_missing_bearer_is_401_not_410() { + let kernel = Arc::new(ScriptedKernel { + get_record: Some(Ok(RecordBlob { + canonical: vec![0xABu8; 8], + record_type: "coinproof".into(), + transition_kind: String::new(), + })), + get_account_state: Some(Ok(AccountStateResult { + account_state: vec![0x01], + state_head: vec![0x02; 32], + head_record_id: Vec::new(), + send_counter: 0, + current_pubkey: vec![0x03; 32], + last_nullifier_pk: Vec::new(), + last_nullifier_r: Vec::new(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/record/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!(kernel.get_record_calls.load(Ordering::SeqCst), 0); + + // Same split on ownership-only account/state. + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/account/state") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!(kernel.get_account_state_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn session_malformed_bearer_is_401_not_410() { + let kernel = Arc::new(ScriptedKernel { + get_coin_proof: Some(Ok(CoinProofBlob { + canonical: vec![0xCDu8; 4], + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/proof/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + .header("authorization", "NotBearer xyz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!(kernel.get_coin_proof_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn session_expired_from_kernel_is_410() { + // Kernel maps unknown/expired/chan_bind-mismatch → session_expired / 410. + let status = encode_kernel_error_status( + Code::Unauthenticated, + "pull session expired or channel mismatch", + "session_expired", + 410, + ); + let kernel = Arc::new(ScriptedKernel { + get_record: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/record/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .header("authorization", "Bearer expired-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::GONE); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "session_expired"); + assert_eq!(kernel.get_record_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn account_state_grant_session_is_401() { + // Kernel enforces ownership-only; a grant session is unauthorized / 401. + let status = encode_kernel_error_status( + Code::Unauthenticated, + "grant session does not authorise GetAccountState", + "unauthorized", + 401, + ); + let kernel = Arc::new(ScriptedKernel { + get_account_state: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/account/state") + .header("authorization", "Bearer grant-session-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!(kernel.get_account_state_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn pull_rejects_unknown_record_type_from_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x55u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Pull.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + let mut result = sample_pull_result(); + result.records[0].record_type = "mystery".into(); + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(result)), + ..Default::default() + }); + let app = build_router(test_config(), kernel).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from( + pull_body_ownership(&subject_bech, &pk0, &nkc, &nonce, expiry, &sig) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + // Kernel closed-set violation → 500 internal_error. Public message is + // always the neutral PUBLIC_INTERNAL_MESSAGE; the field name lives in + // the operator cause / logs only (same contract as get_job_unknown_status). + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "internal_error"); + assert_eq!(json["message"], crate::error::PUBLIC_INTERNAL_MESSAGE); + assert!( + !json["message"].as_str().unwrap().contains("record_type"), + "public wire must not leak kernel field diagnostics: {}", + json["message"] + ); + } + + #[tokio::test] + async fn pull_rejects_unknown_transition_kind_from_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x66u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Pull.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + let mut result = sample_pull_result(); + result.records[0].record_type = "self_delivery".into(); + result.records[0].transition_kind = "explode".into(); + let kernel = Arc::new(ScriptedKernel { + pull: Some(Ok(result)), + ..Default::default() + }); + let app = build_router(test_config(), kernel).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/pull") + .header("content-type", "application/json") + .body(Body::from( + pull_body_ownership(&subject_bech, &pk0, &nkc, &nonce, expiry, &sig) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + // Same contract as unknown record_type: 500 + neutral public message. + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "internal_error"); + assert_eq!(json["message"], crate::error::PUBLIC_INTERNAL_MESSAGE); + assert!( + !json["message"] + .as_str() + .unwrap() + .contains("transition_kind"), + "public wire must not leak kernel field diagnostics: {}", + json["message"] + ); + } + + #[tokio::test] + async fn get_record_returns_binary_octet_stream() { + let kernel = Arc::new(ScriptedKernel { + get_record: Some(Ok(RecordBlob { + canonical: b"canonical-record-bytes".to_vec(), + record_type: "coinproof".into(), + transition_kind: String::new(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/record/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .header("authorization", "Bearer good-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/octet-stream") + ); + let body = body_bytes(res).await; + assert_eq!(body, b"canonical-record-bytes"); + } + + #[tokio::test] + async fn get_account_state_json_shape() { + let kernel = Arc::new(ScriptedKernel { + get_account_state: Some(Ok(AccountStateResult { + account_state: vec![0xAAu8; 16], + state_head: vec![0xBBu8; 32], + head_record_id: vec![0xCCu8; 32], + send_counter: 7, + current_pubkey: vec![0xDDu8; 32], + last_nullifier_pk: vec![0xEEu8; 32], + last_nullifier_r: vec![0xFFu8; 32], + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/account/state") + .header("authorization", "Bearer own-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["send_counter"], 7); + assert_eq!( + json["current_pubkey"].as_str().unwrap().len(), + 64, + "current_pubkey is hex32" + ); + assert_eq!( + json["state_head"].as_str().unwrap().len(), + 64, + "state_head is hex32" + ); + assert!(json["account_state"].as_str().unwrap().len() >= 2); + assert_eq!(json["last_nullifier"]["pubkey"].as_str().unwrap().len(), 64); + // API does not recompute consistency against serialize(AccountState) — + // that is a kernel guarantee (report). + } + + #[tokio::test] + async fn get_proof_returns_binary_octet_stream() { + let kernel = Arc::new(ScriptedKernel { + get_coin_proof: Some(Ok(CoinProofBlob { + canonical: b"coin-proof-bytes".to_vec(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/proof/cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc") + .header("authorization", "Bearer good-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert_eq!( + res.headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/octet-stream") + ); + assert_eq!(body_bytes(res).await, b"coin-proof-bytes"); + } + + // ----------------------------------------------------------------------- + // Receipts stream — GET /v1/receipts/stream (§7.5 L2953–L2955) + // ----------------------------------------------------------------------- + + fn sample_receipt(coin_byte: u8, amount: &str, credited_at: u64) -> Receipt { + Receipt { + coin_id: vec![coin_byte; 32], + asset_id: vec![0xABu8; 32], + amount: amount.to_string(), + state: "completed".into(), + credited_at, + } + } + + #[tokio::test] + async fn receipts_stream_happy_path_two_frames() { + let r1 = sample_receipt(0x11, "1000", 1_700_000_100); + let r2 = sample_receipt(0x22, "250", 1_700_000_200); + let kernel = Arc::new(ScriptedKernel { + subscribe_receipts: Some(Ok(vec![Ok(r1.clone()), Ok(r2.clone())])), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/receipts/stream") + .header("authorization", "Bearer sess-own-1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let ct = match res.headers().get("content-type") { + Some(v) => match v.to_str() { + Ok(s) => s, + Err(e) => panic!("content-type is not ASCII: {e}"), + }, + None => panic!("SSE response missing content-type header"), + }; + assert!( + ct.starts_with("text/event-stream"), + "SSE content-type, got {ct:?}" + ); + let body = String::from_utf8(body_bytes(res).await).expect("utf8"); + + // Frame form: event: receipt\ndata: \n\n (axum SSE). + let event_count = body.matches("event: receipt").count(); + assert_eq!( + event_count, 2, + "must emit exactly two receipt events, body={body}" + ); + assert!( + body.contains("event: receipt\ndata:"), + "frame must be event then data, body={body}" + ); + + // Field encodings: hex32 digests, decimal strings for amount/credited_at. + let coin1 = encode_hex(&r1.coin_id); + let coin2 = encode_hex(&r2.coin_id); + let asset = encode_hex(&r1.asset_id); + assert!( + body.contains(&format!("\"coin_id\":\"{coin1}\"")), + "first coin_id hex, body={body}" + ); + assert!( + body.contains(&format!("\"coin_id\":\"{coin2}\"")), + "second coin_id hex, body={body}" + ); + assert!( + body.contains(&format!("\"asset_id\":\"{asset}\"")), + "asset_id hex, body={body}" + ); + assert!( + body.contains("\"amount\":\"1000\""), + "amount decimal string, body={body}" + ); + assert!( + body.contains("\"amount\":\"250\""), + "second amount decimal string, body={body}" + ); + assert!( + body.contains("\"state\":\"completed\""), + "state literal, body={body}" + ); + assert!( + body.contains("\"credited_at\":\"1700000100\""), + "credited_at decimal string, body={body}" + ); + assert!( + body.contains("\"credited_at\":\"1700000200\""), + "second credited_at decimal string, body={body}" + ); + + // Kernel saw session + chan_bind only (no subject on the wire type). + let req = kernel + .last_subscribe_receipts + .lock() + .expect("mutex") + .clone() + .expect("subscribe_receipts must have been called"); + assert_eq!(req.session, "sess-own-1"); + let expected_cb = chan_bind_for_host("node.example.com"); + assert_eq!(req.chan_bind, expected_cb.to_vec()); + assert_eq!(kernel.subscribe_receipts_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn receipts_stream_grant_session_is_admitted() { + // §7.5 L2953: any still-valid ownership OR grant pull session is + // admissible — contrast with GET /v1/account/state (ownership only). + let r = sample_receipt(0x33, "42", 1_700_000_300); + let kernel = Arc::new(ScriptedKernel { + subscribe_receipts: Some(Ok(vec![Ok(r)])), + // Same grant token on account/state is rejected by the kernel. + get_account_state: Some(Err(crate::kernel::kernel_status_to_api_error( + &encode_kernel_error_status( + Code::Unauthenticated, + "grant session does not authorise GetAccountState", + "unauthorized", + 401, + ), + ))), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/receipts/stream") + .header("authorization", "Bearer grant-session-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::OK, + "grant session must open the receipts stream" + ); + let body = String::from_utf8(body_bytes(res).await).expect("utf8"); + assert!( + body.contains("event: receipt"), + "grant session must receive receipt frames, body={body}" + ); + assert_eq!( + kernel.subscribe_receipts_calls.load(Ordering::SeqCst), + 1, + "kernel SubscribeReceipts must run for a grant session" + ); + + // Contrast: same grant token on account/state → 401 unauthorized. + let res = app + .oneshot( + Request::builder() + .uri("/v1/account/state") + .header("authorization", "Bearer grant-session-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + } + + #[tokio::test] + async fn receipts_stream_missing_bearer_is_401_not_kernel() { + let kernel = Arc::new(ScriptedKernel { + subscribe_receipts: Some(Ok(vec![Ok(sample_receipt(0x01, "1", 1))])), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/receipts/stream") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!( + kernel.subscribe_receipts_calls.load(Ordering::SeqCst), + 0, + "missing bearer must fail at the API edge before any kernel call" + ); + } + + #[tokio::test] + async fn receipts_stream_malformed_bearer_is_401_not_410() { + let kernel = Arc::new(ScriptedKernel { + subscribe_receipts: Some(Ok(vec![Ok(sample_receipt(0x01, "1", 1))])), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/receipts/stream") + .header("authorization", "NotBearer xyz") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!(kernel.subscribe_receipts_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn receipts_stream_unknown_session_is_410() { + // Unknown / expired / chan_bind-mismatch → session_expired / 410 + // (same split as GET /v1/proof/; never collapse into 401). + let status = encode_kernel_error_status( + Code::Unauthenticated, + "pull session expired or channel mismatch", + "session_expired", + 410, + ); + let secret_token = "super-secret-session-token-never-echo"; + let kernel = Arc::new(ScriptedKernel { + subscribe_receipts: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/receipts/stream") + .header("authorization", format!("Bearer {secret_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::GONE); + let body = body_bytes(res).await; + let json: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "session_expired"); + assert_eq!(kernel.subscribe_receipts_calls.load(Ordering::SeqCst), 1); + // Token must not appear in the error body (no log/message leakage). + let body_str = String::from_utf8_lossy(&body); + assert!( + !body_str.contains(secret_token), + "session token must never appear in the error body: {body_str}" + ); + assert!( + !json["message"] + .as_str() + .unwrap_or("") + .contains(secret_token), + "session token must never appear in error message" + ); + } + + #[tokio::test] + async fn receipts_stream_chan_bind_mismatch_is_410() { + // Kernel maps chan_bind mismatch to the same 410 as unknown/expired. + let status = encode_kernel_error_status( + Code::Unauthenticated, + "pull session channel binding mismatch", + "session_expired", + 410, + ); + let kernel = Arc::new(ScriptedKernel { + subscribe_receipts: Some(Err(crate::kernel::kernel_status_to_api_error(&status))), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/receipts/stream") + .header("authorization", "Bearer sess-chan-mismatch") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::GONE); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "session_expired"); + // API still forwarded the authoritative config chan_bind (not Host). + let req = kernel + .last_subscribe_receipts + .lock() + .expect("mutex") + .clone() + .expect("subscribe must have been called"); + assert_eq!( + req.chan_bind, + chan_bind_for_host("node.example.com").to_vec() + ); + } + + #[tokio::test] + async fn receipts_stream_query_subject_is_ignored() { + // Request carries no subject field to the kernel; a client-supplied + // query subject must not change the SubscribeReceiptsRequest. + let r = sample_receipt(0x44, "7", 1_700_000_400); + let kernel = Arc::new(ScriptedKernel { + subscribe_receipts: Some(Ok(vec![Ok(r)])), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/receipts/stream?subject=zk1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq&subject=other") + .header("authorization", "Bearer sess-ignore-subject") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let req = kernel + .last_subscribe_receipts + .lock() + .expect("mutex") + .clone() + .expect("subscribe must have been called"); + assert_eq!(req.session, "sess-ignore-subject"); + assert_eq!( + req.chan_bind, + chan_bind_for_host("node.example.com").to_vec() + ); + // SubscribeReceiptsRequest has only session + chan_bind — no subject + // field exists to populate; the capture proves that is all that was sent. + let _ = req; + } + + #[tokio::test] + async fn receipts_stream_client_disconnect_drops_subscription() { + let kernel = Arc::new(ScriptedKernel { + subscribe_receipts: Some(Ok(vec![])), + subscribe_receipts_hang: true, + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/receipts/stream") + .header("authorization", "Bearer sess-drop") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + assert!( + !kernel.subscribe_receipts_dropped.load(Ordering::SeqCst), + "subscription must still be live while the response is held" + ); + // Dropping the response body tears down the SSE consumer → gRPC stream. + drop(res); + // Allow the async drop path to run. + tokio::task::yield_now().await; + assert!( + kernel.subscribe_receipts_dropped.load(Ordering::SeqCst), + "client disconnect must drop the kernel SubscribeReceipts stream" + ); + assert_eq!(kernel.subscribe_receipts_calls.load(Ordering::SeqCst), 1); + } + + // ----------------------------------------------------------------------- + // Stage D — Bootstrap + Publish + // ----------------------------------------------------------------------- + + use crate::bootstrap::{OPERATIONAL_BUNDLE_HEX_CHARS, OPERATIONAL_BUNDLE_LEN}; + use crate::ownership::{ENTRUST_CHALLENGE_DOMAIN, REVOKE_CHALLENGE_DOMAIN}; + + /// Canonical 161-byte version-0x01 bundle as hex (322 chars). Secrets are + /// zeros — only length/form matters at the API edge in these tests. + fn sample_bundle_hex() -> String { + format!("01{}", "00".repeat(160)) + } + + fn bootstrap_ownership_body( + subject: &str, + pk0: &[u8; 32], + nkc: &[u8; 32], + nonce: &[u8; 32], + expiry: u64, + sig: &[u8; 64], + bundle_hex: Option<&str>, + ) -> Value { + let mut obj = serde_json::json!({ + "challenge": { + "nonce": encode_hex(nonce), + "expiry": expiry.to_string(), + }, + "ownership_proof": ownership_proof_json(subject, pk0, nkc, sig), + }); + if let Some(h) = bundle_hex { + obj.as_object_mut() + .expect("object") + .insert("bundle".into(), Value::String(h.to_string())); + } + obj + } + + #[tokio::test] + async fn bootstrap_challenge_entrust_and_revoke_return_distinct_domains() { + let (_, _, _, _, subject_bech) = ownership_fixtures::identity(); + + // entrust + let kernel = Arc::new(ScriptedKernel { + open_challenge: Some(Ok(Challenge { + nonce: vec![0xABu8; 32], + expiry: 1_700_000_060, + domain: ENTRUST_CHALLENGE_DOMAIN.to_string(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/bootstrap/challenge") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "subject": subject_bech, + "action": "entrust", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["domain"], ENTRUST_CHALLENGE_DOMAIN); + assert_eq!( + kernel.last_open_challenge_action.lock().unwrap().as_deref(), + Some("entrust") + ); + + // revoke + let kernel2 = Arc::new(ScriptedKernel { + open_challenge: Some(Ok(Challenge { + nonce: vec![0xCDu8; 32], + expiry: 1_700_000_120, + domain: REVOKE_CHALLENGE_DOMAIN.to_string(), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel2.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/bootstrap/challenge") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "subject": subject_bech, + "action": "revoke", + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["domain"], REVOKE_CHALLENGE_DOMAIN); + assert_eq!( + kernel2 + .last_open_challenge_action + .lock() + .unwrap() + .as_deref(), + Some("revoke") + ); + assert_ne!(ENTRUST_CHALLENGE_DOMAIN, REVOKE_CHALLENGE_DOMAIN); + assert_ne!(ENTRUST_CHALLENGE_DOMAIN, PULL_CHALLENGE_DOMAIN); + assert_ne!(REVOKE_CHALLENGE_DOMAIN, PULL_CHALLENGE_DOMAIN); + } + + #[tokio::test] + async fn entrust_signed_proof_rejected_on_revoke_endpoint_no_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x11u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + // Sign under Entrust domain — must not authorise /revoke. + let chal = pull_challenge_message( + ChallengeDomain::Entrust.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + let kernel = Arc::new(ScriptedKernel { + revoke: Some(Ok(RevokeResult { revoked: true })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/bootstrap/revoke") + .header("content-type", "application/json") + .body(Body::from( + bootstrap_ownership_body( + &subject_bech, + &pk0, + &nkc, + &nonce, + expiry, + &sig, + None, + ) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "unauthorized"); + assert_eq!(kernel.revoke_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn revoke_signed_proof_rejected_on_entrust_endpoint_no_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x22u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Revoke.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + let kernel = Arc::new(ScriptedKernel { + entrust: Some(Ok(EntrustResult { accepted: true })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let bundle = sample_bundle_hex(); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/bootstrap/entrust") + .header("content-type", "application/json") + .body(Body::from( + bootstrap_ownership_body( + &subject_bech, + &pk0, + &nkc, + &nonce, + expiry, + &sig, + Some(&bundle), + ) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + assert_eq!(kernel.entrust_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn entrust_bundle_160_and_162_are_400_161_is_forwarded() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x33u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Entrust.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + + // 160 bytes → 400, no kernel. + let kernel = Arc::new(ScriptedKernel { + entrust: Some(Ok(EntrustResult { accepted: true })), + ..Default::default() + }); + let short_hex = "01".to_string() + &"00".repeat(159); + assert_eq!(short_hex.len(), 320); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/bootstrap/entrust") + .header("content-type", "application/json") + .body(Body::from( + bootstrap_ownership_body( + &subject_bech, + &pk0, + &nkc, + &nonce, + expiry, + &sig, + Some(&short_hex), + ) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let body = body_bytes(res).await; + let json: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"], "malformed_request"); + assert_eq!(kernel.entrust_calls.load(Ordering::SeqCst), 0); + // Secret must not appear in the error body. + assert!( + !String::from_utf8_lossy(&body).contains(&short_hex), + "bundle hex must not appear in error response" + ); + + // 162 bytes → 400, no kernel. + let long_hex = "01".to_string() + &"00".repeat(161); + assert_eq!(long_hex.len(), 324); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/bootstrap/entrust") + .header("content-type", "application/json") + .body(Body::from( + bootstrap_ownership_body( + &subject_bech, + &pk0, + &nkc, + &nonce, + expiry, + &sig, + Some(&long_hex), + ) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + assert_eq!(kernel.entrust_calls.load(Ordering::SeqCst), 0); + + // 161 bytes → forwarded. + let ok_hex = sample_bundle_hex(); + assert_eq!(ok_hex.len(), OPERATIONAL_BUNDLE_HEX_CHARS); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/bootstrap/entrust") + .header("content-type", "application/json") + .body(Body::from( + bootstrap_ownership_body( + &subject_bech, + &pk0, + &nkc, + &nonce, + expiry, + &sig, + Some(&ok_hex), + ) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["accepted"], true); + assert_eq!(kernel.entrust_calls.load(Ordering::SeqCst), 1); + let last = kernel.last_entrust.lock().unwrap(); + let req = last.as_ref().expect("entrust request captured"); + assert_eq!(req.bundle.len(), OPERATIONAL_BUNDLE_LEN); + assert_eq!(req.bundle[0], 0x01); + assert_eq!(req.subject, subject_bech); + assert_eq!(req.nonce, nonce.to_vec()); + assert_eq!(req.chan_bind, cb.to_vec()); + } + + #[tokio::test] + async fn entrust_auth_failure_response_does_not_contain_bundle_hex() { + // Distinctive non-zero secret hex — if any error path echoes the body, + // this substring will show up. + let marker = "f1e2d3c4b5a69788".repeat(20); // 320 chars of pattern + let bundle = format!("01{}", &marker[..320]); + assert_eq!(bundle.len(), 322); + + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x44u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + // Sign under Revoke so Entrust verification fails after bundle parse. + let chal = pull_challenge_message( + ChallengeDomain::Revoke.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + let kernel = Arc::new(ScriptedKernel { + entrust: Some(Ok(EntrustResult { accepted: true })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/bootstrap/entrust") + .header("content-type", "application/json") + .body(Body::from( + bootstrap_ownership_body( + &subject_bech, + &pk0, + &nkc, + &nonce, + expiry, + &sig, + Some(&bundle), + ) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + let body = String::from_utf8(body_bytes(res).await).expect("utf8"); + assert!( + !body.contains(&bundle), + "full bundle hex must not appear in error body" + ); + assert!( + !body.contains("f1e2d3c4b5a69788"), + "distinctive secret substring must not appear in error body: {body}" + ); + assert_eq!(kernel.entrust_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn revoke_valid_ownership_calls_kernel() { + let host = "node.example.com"; + let (sk, pk0, nkc, subject_raw, subject_bech) = ownership_fixtures::identity(); + let nonce = [0x55u8; 32]; + let expiry = 1_700_000_060u64; + let cb = chan_bind_for_host(host); + let chal = pull_challenge_message( + ChallengeDomain::Revoke.as_str(), + &nonce, + &cb, + &subject_raw, + expiry, + ); + let sig = ownership_fixtures::sign_chal(&sk, &chal); + let kernel = Arc::new(ScriptedKernel { + revoke: Some(Ok(RevokeResult { revoked: true })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/bootstrap/revoke") + .header("content-type", "application/json") + .body(Body::from( + bootstrap_ownership_body( + &subject_bech, + &pk0, + &nkc, + &nonce, + expiry, + &sig, + None, + ) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["revoked"], true); + assert_eq!(kernel.revoke_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn publish_rejection_is_http_200_with_reason() { + let kernel = Arc::new(ScriptedKernel { + publish: Some(Ok(PublishResult { + accepted: false, + reason: Some("invalid_signature".into()), + batch_eta: None, + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "public_key": hex32(0x11), + "r": hex32(0x22), + "s": hex32(0x33), + "r_prime": hex32(0x44), + "block_anchor": { + "block_hash": hex32(0x55), + "height": "100", + } + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/publish/spendrecord") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::OK, + "policy/crypto rejection is a successful hand-off result, not 4xx/5xx" + ); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["accepted"], false); + assert_eq!(json["reason"], "invalid_signature"); + assert!(json.get("batch_eta").is_none()); + assert!(json.get("error").is_none()); + assert_eq!(kernel.publish_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn publish_accepted_returns_batch_eta() { + let kernel = Arc::new(ScriptedKernel { + publish: Some(Ok(PublishResult { + accepted: true, + reason: None, + batch_eta: Some(45), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel).expect("router"); + let body = serde_json::json!({ + "public_key": hex32(0x11), + "r": hex32(0x22), + "s": hex32(0x33), + "r_prime": hex32(0x44), + "block_anchor": { + "block_hash": hex32(0x55), + "height": "42", + } + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/publish/spendrecord") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["accepted"], true); + assert_eq!(json["batch_eta"], "45"); + assert!(json.get("reason").is_none()); + } + + #[tokio::test] + async fn publish_fee_field_is_400_not_silent() { + let kernel = Arc::new(ScriptedKernel { + publish: Some(Ok(PublishResult { + accepted: true, + reason: None, + batch_eta: Some(1), + })), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let body = serde_json::json!({ + "public_key": hex32(0x11), + "r": hex32(0x22), + "s": hex32(0x33), + "r_prime": hex32(0x44), + "block_anchor": { + "block_hash": hex32(0x55), + "height": "100", + }, + "fee_blob_id": hex32(0x66), + }); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/publish/spendrecord") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + assert_eq!( + kernel.publish_calls.load(Ordering::SeqCst), + 0, + "fee field must fail at the edge before any kernel call" + ); + } + + #[tokio::test] + async fn unconfigured_blossom_surfaces_remain_404_and_absent_from_discovery() { + // test_config has blossom: None — Blossom must stay completely off the + // map: unregistered (bare axum 404, not 404 feature_disabled) and + // absent from discovery. receipts_stream is always-on (auth fails closed). + let app = test_app(); + for path in [ + "/blossom/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "/blossom/upload", + ] { + let res = app + .clone() + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::NOT_FOUND, + "unconfigured Blossom surface {path} must not be registered" + ); + // Bare axum 404 has no §7.5 JSON body claiming feature_disabled. + let bytes = body_bytes(res).await; + if let Ok(json) = serde_json::from_slice::(&bytes) { + assert_ne!( + json.get("error").and_then(|e| e.as_str()), + Some("feature_disabled"), + "unconfigured Blossom must be bare 404, not feature_disabled: {json}" + ); + } + } + // Always-on receipts stream is registered: missing bearer → 401, not 404. + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/receipts/stream") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "receipts_stream must be registered; missing bearer is 401" + ); + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + let endpoints = json["endpoints"].as_object().unwrap(); + assert!( + endpoints.contains_key("receipts_stream"), + "receipts_stream is always-on and must appear in discovery" + ); + assert!(!endpoints.contains_key("blossom_get")); + assert!(!endpoints.contains_key("blossom_upload")); + assert!( + endpoints.contains_key("chain_inscriptions"), + "chain_inscriptions is served and must appear in discovery" + ); + assert!(endpoints.contains_key("bootstrap_entrust")); + assert!(endpoints.contains_key("publish_spendrecord")); + } + + // ----------------------------------------------------------------------- + // chain_inscriptions HTTP surface + // ----------------------------------------------------------------------- + + fn sample_inscription_http( + height: u64, + tx_index: u64, + vin_index: u64, + confirmation_state: &str, + member_states: &[&str], + ) -> Inscription { + let mut txid = vec![0u8; 32]; + for (i, b) in txid.iter_mut().enumerate() { + *b = (i as u8).wrapping_add(0x40); + } + let nullifiers: Vec = member_states + .iter() + .enumerate() + .map(|(i, state)| ProtoNullifier { + pubkey: vec![0xA0 + i as u8; 32], + r: vec![0xB0 + i as u8; 32], + state: (*state).to_string(), + }) + .collect(); + Inscription { + txid, + height, + count: nullifiers.len() as u32, + format: 1, + nullifiers, + confirmation_state: confirmation_state.to_string(), + tx_index, + vin_index, + } + } + + #[tokio::test] + async fn chain_inscriptions_failed_member_completed_confirmation() { + // The decisive state split: a later Pk collision is failed while the + // reveal-tx confirmation depth is independently completed. + let kernel = ScriptedKernel { + list_inscriptions: Some(Ok(vec![sample_inscription_http( + 50, + 1, + 0, + "completed", + &["pending", "failed"], + )])), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/chain/inscriptions?limit=10") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + let ins = &json["inscriptions"][0]; + assert_eq!(ins["confirmation_state"], "completed"); + assert_eq!(ins["nullifiers"][0]["state"], "pending"); + assert_eq!(ins["nullifiers"][1]["state"], "failed"); + assert!( + json.get("next_height").is_none(), + "single-page result must omit next_*" + ); + } + + #[tokio::test] + async fn chain_inscriptions_mid_tx_pagination_three_pages() { + // Reveal tx (10,0) carries vin 0/1/2; page boundary cuts between them. + let catalog = vec![ + sample_inscription_http(10, 0, 0, "completed", &["completed"]), + sample_inscription_http(10, 0, 1, "completed", &["completed"]), + sample_inscription_http(10, 0, 2, "completed", &["failed"]), + sample_inscription_http(11, 0, 0, "pending", &["pending"]), + ]; + let kernel = Arc::new(ScriptedKernel { + list_inscriptions: Some(Ok(catalog)), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + + // Page 1 + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/chain/inscriptions?limit=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let p1: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(p1["inscriptions"].as_array().unwrap().len(), 1); + assert_eq!(p1["inscriptions"][0]["vin_index"], 0); + assert_eq!(p1["next_height"], 10); + assert_eq!(p1["next_tx_index"], 0); + assert_eq!(p1["next_vin_index"], 1); + // PAGE_LOOKAHEAD: kernel received limit+1 + // Option is Copy — take by value, no clone. + let last_req = + (*kernel.last_list_inscriptions.lock().expect("mutex")).expect("list called"); + assert_eq!(last_req.limit, Some(2), "PAGE_LOOKAHEAD sends limit+1"); + + // Page 2 — exclusive next of p1 is inclusive from + let res = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/chain/inscriptions?from_height=10&from_tx_index=0&from_vin_index=1&limit=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let p2: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(p2["inscriptions"][0]["vin_index"], 1); + assert_eq!(p2["inscriptions"][0]["height"], 10); + assert_eq!(p2["inscriptions"][0]["tx_index"], 0); + assert_eq!(p2["next_height"], 10); + assert_eq!(p2["next_tx_index"], 0); + assert_eq!(p2["next_vin_index"], 2); + + // Page 3 + let res = app + .oneshot( + Request::builder() + .uri("/v1/chain/inscriptions?from_height=10&from_tx_index=0&from_vin_index=2&limit=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let p3: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(p3["inscriptions"][0]["vin_index"], 2); + assert_eq!(p3["next_height"], 11); + assert_eq!(p3["next_tx_index"], 0); + assert_eq!(p3["next_vin_index"], 0); + // Three distinct triples, mid-tx split, no gap between p1→p2→p3. + // Typed .get/.as_u64 — Index sugar yields a place of type Value; packing + // three places into a by-value tuple would move out of the JSON tree. + let vin_at = |page: &Value, label: &str| -> u64 { + page.get("inscriptions") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|ins| ins.get("vin_index")) + .and_then(|v| v.as_u64()) + .unwrap_or_else(|| { + panic!("{label}: inscriptions[0].vin_index must be present as u64") + }) + }; + assert_eq!( + (vin_at(&p1, "p1"), vin_at(&p2, "p2"), vin_at(&p3, "p3")), + (0, 1, 2), + "mid-reveal-tx pages must cover vin 0,1,2 without gap or duplicate" + ); + } + + #[tokio::test] + async fn chain_inscriptions_limit_zero_is_bounds_exceeded() { + let kernel = ScriptedKernel { + list_inscriptions: Some(Ok(Vec::new())), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/chain/inscriptions?limit=0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "bounds_exceeded"); + assert!( + json["message"].as_str().unwrap().contains("limit"), + "message must name limit, got {}", + json["message"] + ); + } + + #[tokio::test] + async fn chain_inscriptions_limit_non_numeric_is_malformed() { + let kernel = ScriptedKernel { + list_inscriptions: Some(Ok(Vec::new())), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/chain/inscriptions?limit=nope") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + } + + #[tokio::test] + async fn chain_inscriptions_defaults_normalised_before_rpc() { + let kernel = Arc::new(ScriptedKernel { + list_inscriptions: Some(Ok(Vec::new())), + ..Default::default() + }); + let app = build_router(test_config(), kernel.clone()).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/chain/inscriptions") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + // Option is Copy — take by value, no clone. + let req = (*kernel.last_list_inscriptions.lock().expect("mutex")).expect("list called"); + // API normalises defaults before RPC — all fields are Some. + assert_eq!(req.from_height, Some(0)); + assert_eq!(req.from_tx_index, Some(0)); + assert_eq!(req.from_vin_index, Some(0)); + // PAGE_LOOKAHEAD: default rest limit 100 → kernel limit 101 + assert_eq!(req.limit, Some(101)); + } + + // ----------------------------------------------------------------------- + // §7.4 Blossom surface (configured store only) + // ----------------------------------------------------------------------- + + fn blossom_temp_root(label: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "zkcoins-blossom-rt-{}-{}-{}", + label, + std::process::id(), + nanos + )); + let _ = std::fs::remove_dir_all(&root); + root + } + + fn blossom_app(root: std::path::PathBuf, max: u64, ops: BTreeSet<[u8; 32]>) -> Router { + // Blossom mounts only with store **and** wallet|explorer (§6.1 / §7.4). + let cfg = Config { + bind_addr: "127.0.0.1:0".parse().unwrap(), + kernel_addr: "http://127.0.0.1:50051".to_string(), + features: BTreeSet::from([Feature::Explorer, Feature::Wallet]), + public_hosts: vec!["node.example.com".to_string()], + blossom: Some(crate::config::BlossomConfig { + store_root: root, + max_blob_bytes: max, + allowed_upload_ops: ops, + }), + }; + build_router(cfg, Arc::new(UnreachableKernel)).expect("router") + } + + /// Boot must not panic when the Blossom store root cannot be opened — + /// same fail-closed class as other start errors. + #[test] + fn build_router_blossom_open_failure_is_startup_error_not_panic() { + let cfg = Config { + bind_addr: "127.0.0.1:0".parse().unwrap(), + kernel_addr: "http://127.0.0.1:50051".to_string(), + features: BTreeSet::from([Feature::Explorer]), + public_hosts: vec!["node.example.com".to_string()], + blossom: Some(crate::config::BlossomConfig { + // Regular file path cannot be a store root directory. + store_root: std::env::temp_dir().join(format!( + "zkcoins-not-a-dir-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )), + max_blob_bytes: 1024, + allowed_upload_ops: BTreeSet::new(), + }), + }; + // Create a *file* at store_root so open fails "not a directory". + let path = cfg.blossom.as_ref().unwrap().store_root.clone(); + std::fs::write(&path, b"not-a-directory").unwrap(); + let err = build_router(cfg, Arc::new(UnreachableKernel)).expect_err("must not panic"); + assert!( + err.message.contains("blossom store"), + "startup error must name blossom store: {}", + err.message + ); + let _ = std::fs::remove_file(&path); + } + + fn blossom_sk_pk() -> (bitcoin::secp256k1::SecretKey, [u8; 32]) { + use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey}; + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&[0x7au8; 32]).expect("secret"); + let kp = Keypair::from_secret_key(&secp, &sk); + let (xonly, _) = kp.x_only_public_key(); + (sk, xonly.serialize()) + } + + fn blossom_auth( + sk: &bitcoin::secp256k1::SecretKey, + pk: &[u8; 32], + action: crate::blossom::AuthAction, + x: &[u8; 32], + ) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_secs(); + let b64 = crate::blossom::sign_auth_event_base64(sk, pk, action, x, now, now + 120); + format!("Nostr {b64}") + } + + #[tokio::test] + async fn blossom_upload_get_head_roundtrip_bit_equal() { + let root = blossom_temp_root("roundtrip"); + let (sk, pk) = blossom_sk_pk(); + let mut ops = BTreeSet::new(); + ops.insert(pk); + let app = blossom_app(root.clone(), 1_048_576, ops); + let body = b"ciphertext-bytes-for-roundtrip".to_vec(); + let x = crate::blossom::blob_id_of(&body); + let auth = blossom_auth(&sk, &pk, crate::blossom::AuthAction::Upload, &x); + + let res = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/blossom/upload") + .header("content-type", "application/octet-stream") + .header("authorization", &auth) + .body(Body::from(body.clone())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["blob_id"], crate::hexutil::encode_hex(&x)); + assert!( + json.get("receipt").is_none(), + "receipt must be absent without §4.6, got {json}" + ); + + let get = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/blossom/{}", crate::hexutil::encode_hex(&x))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(get.status(), StatusCode::OK); + assert_eq!( + body_bytes(get).await, + body, + "GET must return bit-equal body" + ); + + let head = app + .oneshot( + Request::builder() + .method("HEAD") + .uri(format!("/blossom/{}", crate::hexutil::encode_hex(&x))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(head.status(), StatusCode::OK); + let len = head + .headers() + .get(axum::http::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .unwrap(); + assert_eq!(len, body.len().to_string()); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn blossom_second_upload_same_bytes_is_idempotent() { + let root = blossom_temp_root("idempotent"); + let (sk, pk) = blossom_sk_pk(); + let mut ops = BTreeSet::new(); + ops.insert(pk); + let app = blossom_app(root.clone(), 1024, ops); + let body = b"same-bytes-twice"; + let x = crate::blossom::blob_id_of(body); + for _ in 0..2 { + let auth = blossom_auth(&sk, &pk, crate::blossom::AuthAction::Upload, &x); + let res = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/blossom/upload") + .header("content-type", "application/octet-stream") + .header("authorization", &auth) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["blob_id"], crate::hexutil::encode_hex(&x)); + } + let store = crate::blossom::BlobStore::open(&root).unwrap(); + let names = store.list_root_names().unwrap(); + let blob_files: Vec<_> = names + .iter() + .filter(|n| n.len() == 64 && !n.contains('.')) + .collect(); + assert_eq!(blob_files.len(), 1); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn blossom_path_traversal_is_400_and_does_not_touch_outside() { + let root = blossom_temp_root("traversal"); + let outside = root + .parent() + .unwrap() + .join(format!("zkcoins-blossom-outside-{}", std::process::id())); + std::fs::write(&outside, b"sentinel").unwrap(); + let outside_before = std::fs::read(&outside).unwrap(); + let app = blossom_app(root.clone(), 1024, BTreeSet::new()); + let store_before = crate::blossom::BlobStore::open(&root) + .unwrap() + .list_root_names() + .unwrap(); + + for bad in [ + format!("/blossom/{}", "A".repeat(64)), + format!("/blossom/{}", "a".repeat(63)), + format!("/blossom/{}", "a".repeat(65)), + "/blossom/../etc/passwd".to_string(), + ] { + let res = app + .clone() + .oneshot(Request::builder().uri(&bad).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert!( + res.status() == StatusCode::BAD_REQUEST || res.status() == StatusCode::NOT_FOUND, + "path {bad} → {}", + res.status() + ); + if res.status() == StatusCode::BAD_REQUEST { + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + } + } + + let store_after = crate::blossom::BlobStore::open(&root) + .unwrap() + .list_root_names() + .unwrap(); + assert_eq!(store_before, store_after); + assert_eq!(std::fs::read(&outside).unwrap(), outside_before); + let _ = std::fs::remove_file(&outside); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn blossom_upload_rejects_non_peer_op_with_403() { + let root = blossom_temp_root("nonpeer"); + let (sk, pk) = blossom_sk_pk(); + let app = blossom_app(root.clone(), 1024, BTreeSet::new()); + let body = b"not-a-peer"; + let x = crate::blossom::blob_id_of(body); + let auth = blossom_auth(&sk, &pk, crate::blossom::AuthAction::Upload, &x); + let res = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/blossom/upload") + .header("content-type", "application/octet-stream") + .header("authorization", &auth) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::FORBIDDEN); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "scope_exceeded"); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn blossom_upload_rejects_oversize_with_413() { + let root = blossom_temp_root("oversize"); + let (sk, pk) = blossom_sk_pk(); + let mut ops = BTreeSet::new(); + ops.insert(pk); + let app = blossom_app(root.clone(), 4, ops); + let body = b"12345"; + let x = crate::blossom::blob_id_of(body); + let auth = blossom_auth(&sk, &pk, crate::blossom::AuthAction::Upload, &x); + let res = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/blossom/upload") + .header("content-type", "application/octet-stream") + .header("authorization", &auth) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "payload_too_large"); + let _ = std::fs::remove_dir_all(&root); + } + + /// Bodies far above the limit (not only max+1) must still answer with the + /// §7.5 JSON `payload_too_large` body — not axum's plain-text 413. + #[tokio::test] + async fn blossom_upload_rejects_far_oversize_with_413_json() { + let root = blossom_temp_root("far-oversize"); + let (sk, pk) = blossom_sk_pk(); + let mut ops = BTreeSet::new(); + ops.insert(pk); + let max = 16u64; + let app = blossom_app(root.clone(), max, ops); + // Several times the limit so DefaultBodyLimit trips well past max+1. + let body = vec![0xabu8; (max as usize) * 64]; + let x = crate::blossom::blob_id_of(&body); + let auth = blossom_auth(&sk, &pk, crate::blossom::AuthAction::Upload, &x); + let res = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/blossom/upload") + .header("content-type", "application/octet-stream") + .header("authorization", &auth) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::PAYLOAD_TOO_LARGE); + let bytes = body_bytes(res).await; + let json: Value = serde_json::from_slice(&bytes).unwrap_or_else(|e| { + panic!( + "far-oversize must return §7.5 JSON, not plain text: {e}; body={:?}", + String::from_utf8_lossy(&bytes) + ) + }); + assert_eq!(json["error"], "payload_too_large"); + assert!(json.get("message").is_some()); + let _ = std::fs::remove_dir_all(&root); + } + + /// Non-tx JSON handlers must also map bad content-type to §7.5 JSON + /// (not axum's default 415/422 body). + #[tokio::test] + async fn post_sign_missing_json_content_type_is_malformed_request() { + let kernel = ScriptedKernel { + sign: Some(Ok(accepted_job("job-ct"))), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/jobs/job-ct/sign") + .body(Body::from(r#"{"signature":"aa","s2c_nonce":"bb"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + } + + /// Unknown job status from the kernel is fail-closed 500 (not forwarded + /// as a non-terminal poll with Retry-After). + #[tokio::test] + async fn get_job_unknown_status_is_500_internal() { + let kernel = ScriptedKernel { + get: Some(Ok({ + let mut j = accepted_job("j-bad"); + j.status = "not_a_status".into(); + j + })), + ..Default::default() + }; + let app = build_router(test_config(), Arc::new(kernel)).expect("router"); + let res = app + .oneshot( + Request::builder() + .uri("/v1/jobs/j-bad") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::INTERNAL_SERVER_ERROR); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "internal_error"); + assert_eq!(json["message"], crate::error::PUBLIC_INTERNAL_MESSAGE); + } + + #[tokio::test] + async fn blossom_upload_rejects_json_content_type_as_malformed_request() { + let root = blossom_temp_root("jsonct"); + let (sk, pk) = blossom_sk_pk(); + let mut ops = BTreeSet::new(); + ops.insert(pk); + let app = blossom_app(root.clone(), 1024, ops); + let body = b"{}"; + let x = crate::blossom::blob_id_of(body); + let auth = blossom_auth(&sk, &pk, crate::blossom::AuthAction::Upload, &x); + let res = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/blossom/upload") + .header("content-type", "application/json") + .header("authorization", &auth) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + // §7.4 non-conforming form → 400 malformed_request (closed §7.5 set). + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["error"], "malformed_request"); + let _ = std::fs::remove_dir_all(&root); + } + + /// Data permanence: DELETE is not registered. Path may match GET/HEAD so + /// axum answers 405 Method Not Allowed; a bare 404 is also acceptable if + /// the method is not merged onto the route table. The stored blob must + /// remain readable after any DELETE attempt. + #[tokio::test] + async fn blossom_delete_is_not_registered_and_blob_persists() { + let root = blossom_temp_root("delgone"); + let (sk, pk) = blossom_sk_pk(); + let mut ops = BTreeSet::new(); + ops.insert(pk); + let app = blossom_app(root.clone(), 1024, ops); + let body = b"must-survive-delete-attempt"; + let x = crate::blossom::blob_id_of(body); + let auth_up = blossom_auth(&sk, &pk, crate::blossom::AuthAction::Upload, &x); + let res = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/blossom/upload") + .header("content-type", "application/octet-stream") + .header("authorization", &auth_up) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["blob_id"], crate::hexutil::encode_hex(&x)); + assert!( + json.get("receipt").is_none(), + "upload must not emit receipt, got {json}" + ); + + let del = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/blossom/{}", crate::hexutil::encode_hex(&x))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!( + del.status() == StatusCode::METHOD_NOT_ALLOWED || del.status() == StatusCode::NOT_FOUND, + "DELETE must not succeed; got {}", + del.status() + ); + + let get = app + .oneshot( + Request::builder() + .uri(format!("/blossom/{}", crate::hexutil::encode_hex(&x))) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(get.status(), StatusCode::OK); + assert_eq!( + body_bytes(get).await, + body, + "blob must remain after DELETE attempt" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[tokio::test] + async fn blossom_discovery_keys_bound_to_configuration() { + // Without store (test_config): absent. + let app = test_app(); + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + let endpoints = json["endpoints"].as_object().unwrap(); + for k in [ + "blossom_get", + "blossom_head", + "blossom_upload", + "blossom_delete", + ] { + assert!(!endpoints.contains_key(k), "{k} unadvertised without store"); + } + + // With store: get/head/upload present; delete never advertised. + let root = blossom_temp_root("disc"); + let app = blossom_app(root.clone(), 1024, BTreeSet::new()); + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + let endpoints = json["endpoints"].as_object().unwrap(); + assert_eq!(endpoints["blossom_get"], "/blossom/"); + assert_eq!(endpoints["blossom_head"], "/blossom/"); + assert_eq!(endpoints["blossom_upload"], "/blossom/upload"); + assert!( + !endpoints.contains_key("blossom_delete"), + "data permanence: blossom_delete must never be advertised" + ); + let _ = std::fs::remove_dir_all(&root); + } + + /// Receipt-binding headers are ignored (no §4.6); upload still returns + /// only `{ blob_id }` with no `receipt` field. + #[tokio::test] + async fn blossom_upload_ignores_legacy_binding_headers_and_omits_receipt() { + let root = blossom_temp_root("bindok"); + let (sk, pk) = blossom_sk_pk(); + let mut ops = BTreeSet::new(); + ops.insert(pk); + let app = blossom_app(root.clone(), 1024, ops); + let body = b"with-legacy-binding-headers"; + let x = crate::blossom::blob_id_of(body); + let auth = blossom_auth(&sk, &pk, crate::blossom::AuthAction::Upload, &x); + let res = app + .oneshot( + Request::builder() + .method("PUT") + .uri("/blossom/upload") + .header("content-type", "application/octet-stream") + .header("authorization", &auth) + .header( + "x-zkcoins-event-id", + crate::hexutil::encode_hex(&[0xaa; 32]), + ) + .header( + "x-zkcoins-attempt-nonce", + crate::hexutil::encode_hex(&[0xbb; 32]), + ) + .header("x-zkcoins-retention", "indefinite") + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let json: Value = serde_json::from_slice(&body_bytes(res).await).unwrap(); + assert_eq!(json["blob_id"], crate::hexutil::encode_hex(&x)); + assert!( + json.get("receipt").is_none(), + "receipt must be absent, got {json}" + ); + // Object keys are exactly blob_id (no optional receipt key). + let obj = json.as_object().expect("object"); + assert_eq!( + obj.keys().collect::>(), + vec!["blob_id"], + "upload body must be only {{ blob_id }}" + ); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..abeec7f --- /dev/null +++ b/src/state.rs @@ -0,0 +1,40 @@ +//! Shared axum application state. +//! +//! Handlers that need only the kernel extract `State` via +//! [`FromRef`]; handlers that also need API-owned config (e.g. `features` +//! for `GET /v1/info`, `public_hosts` for OwnershipProof `chan_bind`, +//! optional Blossom store) extract `State`. + +use crate::blossom::BlossomState; +use crate::config::Feature; +use crate::kernel::KernelHandle; +use crate::ownership::{RevokedGrantSet, SubjectOpDirectory}; +use axum::extract::FromRef; +use std::collections::BTreeSet; +use std::sync::Arc; + +/// Process state bound into the router after registration. +#[derive(Clone)] +pub struct AppState { + pub kernel: KernelHandle, + /// API-layer §6.1 features (`ZKCOINS_FEATURES`). The kernel never + /// supplies these — `Info.kernel_parts` is a different closed set. + pub features: BTreeSet, + /// Authoritative public hostnames for §5.1 `chan_bind` + /// (`ZKCOINS_PUBLIC_HOST`). Never derived from request headers. + pub public_hosts: Arc>, + /// §7.4 Blossom surface. `None` when `ZKCOINS_BLOSSOM_STORE` is unset — + /// routes are not mounted and discovery keys are not advertised. + pub blossom: Option, + /// Published `op_pubkey` by subject for GrantProof step 1 (§5.1(b)). + /// Starts empty — see [`SubjectOpDirectory`]. + pub subject_ops: Arc, + /// Forward-only grant revocation set (§5.2). + pub revoked_grants: Arc, +} + +impl FromRef for KernelHandle { + fn from_ref(state: &AppState) -> Self { + state.kernel.clone() + } +}