019ffcd1 - Allow any verified blossom op on test nodes - #3
Conversation
A dedicated test node cannot pre-list every fixture wallet. The sole token * accepts any kind-24242 that already verifies; mixing * with hex keys is a start error. Empty still 403s.
The rest-surface table and image comments still described only hex keys. Record the sole-* token and use BTreeSet::contains so -D warnings stays green.
Config empty-ops stays deny-all. A verified unlisted kind-24242 uploads under allow_any and is still 401 without Authorization.
|
EN: Ready after 2 review passes. The change lets a test node accept any already-verified kind-24242 when DE: Bereit nach 2 Review-Durchläufen. Die Änderung lässt einen Test-Node jedes bereits verifizierte Kind-24242 zu, wenn DetailsPass 1: logic asked for allow-any upload coverage. Added empty-ops parse, verified unlisted upload 200, missing Authorization 401, empty ACL 403. Pass 2: quality and logic FINDINGS 0. Comments: none on the PR, no reviews, no inline comments, no review threads. Mergability: MERGEABLE against feat/v1-api-layer. All four commits verified. CI on fc64353 after ready_for_review: Lint & Build SUCCESS. The earlier draft-PR skip is superseded by that run. |
* feat: scaffold the API process with an honest discovery document
This repo is the public REST surface of specification §7.5; the node is
the kernel behind it (§6.1). The scaffold puts the process skeleton in
place without pretending to serve anything it does not.
Configuration is fail-closed. ZKCOINS_BIND_ADDR, ZKCOINS_KERNEL_ADDR and
ZKCOINS_FEATURES are all required: absence is a named startup error, not
a default. There is no fallback bind host, no default kernel address,
and an unknown feature token aborts the boot naming both the token and
the closed set. A test asserts explicitly that a missing bind address
does not quietly become 127.0.0.1.
Two routes exist, GET / and GET /health, and GET /v1/info is covered by
a test that requires it to 404 -- a documented gap rather than a
placeholder handler.
The discovery document deserves a note, because the first version got it
wrong. It emitted all 29 closed §7.5 keys while the router registered
two, so a client reading it and calling /v1/info would get a 404. The
spec is explicit that a producer emits the closed keys "for the surfaces
this deployment exposes" and MUST omit keys for unadvertised roles. The
test asserting 29 keys made that claim a gate: green precisely because
the answer was untrue.
Registration and advertisement now derive from one source, ServedSurface,
so they cannot drift. A new variant without a handler is a compile error;
a served key missing from the inventory aborts at router construction;
and a test walks every advertised path and requires it to be reachable --
that test would have been red against the previous version at 28 of 29
keys. CLOSED_ENDPOINT_KEYS survives as the full inventory for surfaces
not yet built, checked against the spec ordering, and is deliberately not
what GET / returns.
Config::features stays unread for now. Reading it would either advertise
keys with no handlers or filter nothing; the parameter is destructured
by name so that adding a config field is a compile error rather than a
silent omission.
Verified locally: fmt, check --all-targets, clippy -D warnings, and 17
tests passing. The crate did not compile at all before this -- two errors
sat in config.rs and had never been built.
* feat: add the kernel gRPC client and the §7.5 job surface
The node is a kernel: gRPC only. This is the REST side, and it holds no protocol
state, no database and no secrets — it translates §7.5 onto `kernel.v1` and back.
Five endpoints, all from the closed §7.5 inventory: `POST /v1/tx`,
`GET /v1/jobs/{id}`, `GET /v1/jobs/{id}/stream`, `POST /v1/jobs/{id}/sign` and
`POST /v1/jobs/{id}/cancel`, mapped onto `SubmitTransition`, `GetJob`,
`StreamJob`, `SignTransition` and `CancelJob`.
**The HTTP status comes only from `ErrorInfo.metadata["http_status"]`.** There is
one error table and it lives in the node; a second one here would be the drift
this project keeps removing. A status without a `kernel.v1` `ErrorInfo`, or with
an unusable `http_status`, fails closed rather than being guessed from the gRPC
code. Building this side is what exposed that the node was sending private
metadata headers instead of the normative detail — fixed there, in its own
commit.
The `.proto` is carried in this repo and pinned by content hash, with a test that
compares byte-for-byte against the node's copy when it is checked out alongside.
A copy without an equality check is a wire-drift source, and wire drift between
two repos is exactly the failure nobody sees until it is live. Codegen sits in
its own `kernel-proto` crate on tonic 0.13.1, matching the node line where
`tonic-build` still owns prost codegen.
Two findings from getting the tests to pass, both worth naming.
The routes were registered under the **spec's** path spelling — `/v1/jobs/<job_id>`
— which axum takes as a literal, so every real request 404'd. The advertised form
and the matcher form are two projections of one source now, derived by rewriting
rather than maintained as two lists, and `GET /` still advertises the spec
spelling because that is the contract.
Worse: the test named "every advertised endpoint is reachable" **passed**
throughout. It requested the advertised string itself, which matched the literal
route, so its input and its expectation came from the same place — a tautology
with a name, like the `MAX_RX_COINS + 1 > MAX_RX_COINS` assertion in the node. It
now substitutes a concrete value into each placeholder and distinguishes a
routing 404 (axum fallback, empty body) from a domain 404 (§7.5 body with a
`reason`), which is what makes it able to fail.
The test double replaces the kernel process, not the REST↔proto logic: it emits
domain errors through the same `ErrorInfo` encoding the real kernel uses — one
detail, `reason`, `domain`, `http_status` — so a passing error test is evidence
about the real wire form. It needs no Plonky2 proof.
`kernel-proto` carries `#![allow(clippy::all)]` at its root, as the node's does.
That crate contains nothing but generator output, so there is no finding to
suppress; the doc comment says the allow stops applying the moment hand-written
logic appears there.
* feat: add info, readiness and the chain read surface
Four more §7.5 endpoints: `GET /v1/info`, `GET /health/ready`,
`GET /v1/chain/accumulator` and `GET /v1/chain/nullifier/<pubkey>`, onto
`GetInfo`, `GetAccumulator` and `GetNullifierPath`.
This is the surface an outsider uses to check that a transition actually
landed. A job reporting `completed` proves it was applied locally and handed to
the broadcast path — not chain inclusion, not the scanner fold, not finality. So
the standard is the same one the node holds itself to: every field is passed
through from the canonical source, and the API computes nothing. The NAV root
stays `Hc("NfLog/Root", size ‖ mth)` as the kernel produced it; recomputing or
"checking" it here would create the second truth this project keeps deleting.
`GET /v1/chain/nullifier/<pubkey>` keeps present and absent apart, and an absent
answer omits `position` and `leaf` rather than sending zeros. A kernel
`internal_error` — a corrupt index, say — surfaces as 500, never as
`present: false`. That third case is the one worth a test, because collapsing an
error into "not there" is how a proof surface starts lying.
`GET /health/ready` reads `ready` and the closed `ready_reason` from `GetInfo`
rather than inventing a second readiness notion. In production today `GetInfo`
fails closed, because `ChainIdentity` is `None` in the node — so this endpoint
answers `503 { ready: false, reason: "dependency_unavailable" }`. That is the
honest answer; a `200 ok` derived from a failed call would be the worst
available. It also refuses to emit a `root` without its `size`: §7.5 pairs them,
and half a pair is a made-up fact.
**`chain_inscriptions` is deliberately not served or advertised.**
`ListInscriptions` answers `Unimplemented` in the node until a scanner-written
inscription catalogue exists, and a REST shell that reliably returns 501 is not
progress — it is a second place to look to learn the same absence.
`features` is API configuration, not `kernel_parts`, and its order is now fixed
rather than inherited from the environment variable: an endpoint that returns the
same set in a different order on two calls makes every response comparison lie.
The set is closed, so an unknown token in `ZKCOINS_FEATURES` stops the process at
startup, the same fail-closed line as the missing kernel address.
* feat: verify the ownership proof at the API edge, and add attest and grants
Four §7.5 endpoints: the attest-balance and grant challenges and their
redemptions, onto `OpenPullChallenge`, `AttestBalance` and `IssueViewGrant`.
The ownership proof is verified **here**, not in the kernel — that boundary was
already settled, and the gRPC surface carries no ownership-proof field, with a
node test pinning it. So this commit builds a verification that did not exist
anywhere: `chal = H(domain ‖ nonce ‖ chan_bind ‖ subject ‖ expiry ‖
request_hash)`, checked as BIP-340 under the subject's key using the same
`bitcoin`/`secp256k1` line the node uses.
**The domain follows the endpoint, never the body.** `POST /v1/attest/balance`
verifies under `zkCoins/v1/AttestBalanceChallenge`, `POST /v1/grants` under
`zkCoins/v1/IssueGrantChallenge`, taken verbatim from the node's
`ChallengeAction::domain()`. A caller cannot choose it. This matters more than
it looks: the node's action binding is *structural* — separate maps per action —
and that made everything look right, while the cryptographic separation was
missing entirely, because it lives on the other side of the boundary and nobody
had built that side. The test that matters here runs it both ways: a proof signed
for attest does not authorise a grant, and vice versa.
`chan_bind` comes from server configuration, never from a `Host` header or a
client field, and `request_hash` is computed from the parsed body the server
actually sees — not from a hash the client supplies. Both are checked at
redemption, not only at issuance.
Verification runs before any kernel call, so an invalid signature, a wrong
domain, a wrong `chan_bind` or an altered body is `401 unauthorized` **and the
nonce is not consumed**. Otherwise a typo would be a denial of service against
the rightful owner. The tests assert the kernel call count is zero on every
rejection path.
The API holds no challenge state: the store lives in the kernel, and this layer
verifies and forwards.
**One gap, and it is a spec question rather than a defect here.** Reconstructing
`chal` needs the challenge's `expiry`, which the kernel holds and §7.5's
redemption body does not carry — it lists `{ nonce }`. A monolith reads it from
its own store; a two-process split cannot, if the proof must be verified before
the nonce is consumed. The current handler therefore accepts `{ nonce, expiry }`,
which is an extension of the normative body and is called out as such rather than
quietly shipped. Closing it properly is a decision about the spec — either the
body carries the issued `expiry`, or the kernel gains a non-consuming lookup — and
that is not something to invent in an implementation.
* feat: add pull, records, coin proofs and the account state read
Five more §7.5 endpoints, completing the private read surface: the pull
challenge and its redemption, `GET /v1/record/<id>`, `GET /v1/proof/<coin_id>`
and `GET /v1/account/state`, onto `OpenPullChallenge`, `Pull`, `GetRecord`,
`GetCoinProof` and `GetAccountState`.
`POST /v1/pull` carries the challenge `expiry` beside `nonce`, matching the
specification change that made the redeem body usable from a stateless API
layer. It is verified into `chal` before anything is consumed, so a bad proof
never spends the nonce.
The session authority follows the **proof type** and nothing else. It reaches
the kernel explicitly, and a missing value is `malformed_request` rather than a
silent assumption of ownership — the failure that would matter most here is the
one that quietly upgrades a reader to an owner.
The three session failures stay apart, as §7.5 requires: a missing or unusable
bearer is `401 unauthorized` decided here, while unknown, expired or
channel-mismatched sessions are `410 session_expired` from the kernel. A grant
session presented to `GET /v1/account/state` is `401` — read access to a slice
is not a right to the account state.
**The grant path is rejected outright, and that is the honest outcome.** §5.1(b)
verification needs the issuer's published `op` public key to check the grant's
signature, and this layer has no way to obtain it: no lookup, no kernel
procedure, no protocol state — and it must not hold any. Checking the discriminator
and the grantee's own signature while skipping the issuer signature would be a
half-verified grant, which is worse than none: it looks like authorisation and
is not. So `POST /v1/pull` with a `GrantProofJson` answers `401 unauthorized`
without calling the kernel, and what is missing to close it properly is written
down rather than approximated.
`record_type` and `transition_kind` stay closed sets on the way out; an
unrecognised value from the kernel is not forwarded. Nothing the kernel computes
is recomputed here.
* feat: add the bootstrap endpoints and the publisher hand-off
`POST /v1/bootstrap/challenge`, `/entrust`, `/revoke` and the §7.6 publisher
hand-off, onto `OpenPullChallenge`, `EntrustOperationalBundle`,
`RevokeOperationalBundle` and `Publish`. That completes every §7.5 surface with
something behind it.
The action names the domain at issuance — that is what the body is for there —
but at redemption the domain follows the **endpoint**, as it does for attest and
grants. A proof signed for `revoke` cannot authorise `entrust`, and the test runs
it both ways. Both domains are distinct from the pull domain so a proof cannot be
repurposed at all.
**The entrust body is the only object on this whole surface that carries real
secrets** — five 256-bit keys in 161 bytes. Everything else here is public or
already bound. So the request body is never logged, never traced, never put in an
error message, and a test pins that: a framework default that echoes bodies would
otherwise write five keys into a file. The length and hex form are checked before
the call, so an obviously wrong body does not travel; the kernel checks them
again, which is intentional.
A rejected publish stays a **successful** response carrying the closed reason,
not a 4xx — the network declining an inscription is a result, and the job side
already draws that line the same way. A set v1 fee field is `400
malformed_request` rather than silently ignored.
Four keys stay unserved and unadvertised, each for a reason written in the code:
`chain_inscriptions` and `receipts_stream` because the kernel answers
`Unimplemented` and names its missing precondition, and the four `blossom_*`
keys because §7.4 has no implementation at all — `recovery` is demonstrably not
built. A REST shell over an absent subsystem is a map with invented streets.
* docs: name the real prerequisite for grant verification (Nostr profile, not a config field)
The reject path said the API has 'no kernel RPC that returns op_pubkey for a
subject', which points at the wrong fix. Per §1.2 the op key is node-held and
is published as the author of the subject's kind-0 profile (§7.3), so a node
the subject does not control cannot be handed it as a setting and the kernel
cannot supply it either — it knows its own op, not a foreign subject's.
Getting it means resolving that profile and running the §4.3 address binding
on the result. Without all three checks an attacker who knows the subject's
public pk0 and nk_commit publishes a profile naming their own op_pubkey, and
the grant then verifies against the forger's key.
Behaviour is unchanged: GrantProof stays a loud 401.
* feat: serve chain_inscriptions now that the node has a catalogue
The endpoint was unadvertised and 404 because the kernel answered Unimplemented:
the NfLog carries winning (pk, r) pairs and a chain position, not the reveal
txid and not the §3.5 format byte. The node now writes an inscription catalogue
at fold time, so the surface can be served from real data rather than from a
projection that would have had to invent two fields.
Two states in the response are nearly homonymous and are not the same thing.
`nullifiers[i].state` is that member's §3.10 state and members of one aggregate
may legitimately differ — a later `Pk` collision is `failed` while earlier
members stay `pending` or `completed`. `confirmation_state` is only the reveal
transaction's depth against the §3.9 six-confirmation floor and never carries
`failed`. A test shows both at once: a `failed` member inside an inscription
whose `confirmation_state` is `completed`.
The triple cursor is all-or-nothing by construction rather than by a check at
the end, so a response carrying a proper subset of `next_height` /
`next_tx_index` / `next_vin_index` is not representable. Page continuation is
tested across three pages with a page boundary falling inside a reveal
transaction that carries several `vin_index` inscriptions — that is where
duplicates and gaps appear.
`limit` outside 1..1000 is rejected rather than clamped, and the §7.5 defaults
are named constants carrying their spec reference rather than an implicit
`unwrap_or_default` that hides the value.
* feat: serve the Blossom blob store (§7.4)
Four of the nine unserved §7.5 keys — `blossom_get`, `blossom_head`,
`blossom_upload`, `blossom_delete`. §7.8 has no Blossom procedure: the surface
is explicitly API-local and carries its own storage, so it does not go through
the kernel.
The store is content-addressed on the filesystem under a root that comes from
the operating configuration. There is no default path and no fallback: without
the variable the surface is unconfigured, the routes are not mounted and the
four discovery keys are not advertised — the same shape the repo already uses
for other unconfigured surfaces.
The path parameter is validated as exactly 64 lowercase hex characters *before*
it becomes a filename, so traversal is excluded by construction rather than by
filtering. Writes go to a temporary file in the same directory and are renamed
into place: an interrupted upload must not leave a half blob under a valid
address, which is the one promise a content-addressed store makes.
The `x` tag of the kind-24242 authorization event is checked against the bytes
actually received, not against a header or a value inside the event. That check
is the whole authorization: anyone who could swap the body without breaking the
tag could store arbitrary content under someone else's signature.
Every §7.4 rejection is its own check with its own status — 401 for signature,
kind, `t` tag, `x` tag, expiry and the clock window; 403 for the wrong `op` key
on upload or a delete by someone other than the original uploader; 413 over the
size limit. A blob with no uploader note is not deletable rather than deletable
by anyone.
The three `X-ZkCoins-*` binding headers are all-or-nothing and validated even
though no receipt follows: §4.6 replication is not built, so the optional
`receipt` field stays absent rather than becoming an empty object or an
invented attestation.
* feat: serve GET /v1/receipts/stream — 26 of 29 §7.5 keys
The kernel now writes receipts after durable persistence and filters them by
the server-side session subject and resolved scope, so the last non-legacy §7.5
key can be served. The three that remain are legacy 410 surfaces.
The auth rule here is deliberately not the one next door. §7.5 admits **any**
still-valid ownership *or* grant pull session on this stream, while
`GET /v1/account/state` admits ownership sessions only — a scoped grant must
never see full account state, but it may see the receipts inside its scope. A
test holds that difference, because a suite that only ever opens one kind of
session looks equally green whichever way the check goes.
Subject and scope come from the server-side session state. The request carries
no subject anywhere, and a test asserts that supplying one changes nothing.
The error shapes stay separated the way §7.5 separates them: a missing or
non-session bearer is `401 unauthorized`, while an unknown, expired or
`chan_bind`-mismatching session is `410 session_expired`. Collapsing them would
leave a client unable to tell "authenticate again" from "open a new session".
No recovery buffer and no sequence numbers: §4.9 makes the stream a latency
accelerator, never the sole source of truth, and a client recovers missed
receipts through the ordinary pull endpoint. Building either would promise
something the spec deliberately does not.
* build: containerise the API so the local stack can be complete
The stack has four running services and still could not do a full run, because
one part was not containerised: `docs/local-stack.md` in the node repo told the
reader the API "runs alongside". That is not a stack — it is a request that the
reader supply the missing piece.
Multi-stage build on the pinned toolchain, non-root at runtime, `protoc` pinned
to the distribution's exact version rather than floating (the first build
failed on a version that does not exist in bookworm; the one in the image is
`3.21.12-3+deb12u1`). The exposed port is the one `main.rs` actually binds, not
one copied from the node's Dockerfile.
No defaults are baked into the image. Every variable `Config::from_env` reads
fail-closed is documented in the header with its meaning, whether it is
required, and the source line — including the all-or-nothing Blossom triple,
where setting the store without its companions is a start error rather than a
half-configured surface.
* ci: give the API its own gate, paused like the rest of the tree
The API was the only one of the four repositories without any workflow at
all — no formatting check, no clippy, no build, no test ran on it anywhere
except a developer's terminal. It now carries the same four gates the tree
is verified against locally: `cargo fmt --all --check`, clippy over
`--all-targets --all-features` with warnings denied, a build, and the test
suite.
`--all-targets` is deliberate and must stay. Without it clippy lints the
library targets only, so the test and fixture code — which is most of what
decides whether a green run means anything — would never be linted at all.
The workflow lands in the same paused state as the other repositories:
`workflow_dispatch` is live so a run can still be started by hand, and the
`pull_request:` trigger sits commented out immediately below, verbatim, so
switching hosted CI back on at the end of the rebuild is a deletion rather
than a rewrite. Nothing else is parked: every gate above is the one that
will run on the first PR after the trigger is restored.
The toolchain step installs the pin from `rust-toolchain`
(nightly-2026-06-18) rather than a floating channel. With `-D warnings` a
moving channel turns a new lint into a red build on a tree nobody touched.
There is no `notify-failure` job: this repository does not hold the
Telegram secrets. The comment above the job records what to model it on
once it does.
* feat: verify GrantProof properly, and stop widening the scope
Two halves of the capability-gated pull surface were missing, and the
second one mattered more than the first.
`GrantProof` verification was marked not implemented, so every
grant-bearing request was refused outright — the authorised mode of §5
had no path at all. It now runs the §5.2 checks in the order the
specification lays down: decode the `zkgrant`, recompute `grant_message`
from the fixed field concatenation and verify BIP-340 under the
subject's published `op_pubkey`, bind the grantee's identity and its
signature over the challenge, then expiry and revocation. Any failure
refuses before the kernel is dialled, so a bad proof can never burn a
nonce.
The scope was the real defect. Ownership was requested with an unbounded
scope regardless of what the presented capability actually covered — a
release wider than the grant it came from, which is precisely what §5
forbids. The effective scope is now the intersection: `max` on
`not_before`, `min` on `not_after`, an explicit foreign asset refused
rather than merged, and an empty result refused instead of silently
falling through. A wildcard request clamps to the narrower grant; it
never widens it. The resolved scope is recorded server-side on the
session, and later use reads it from there rather than from the request.
`grant_message_digest` keeps its eight parameters and an explicit lint
exemption: that list *is* the normative formula, and folding it into a
struct would invite the field order of the struct to be mistaken for the
binding one — the exact confusion §5.2 warns about. `verify_grant_proof`
went the other way: `public_hosts`, `now` and the revocation set are
environment rather than evidence, so they moved into a context struct.
One of the existing tests promised in its comment to send a real
`zkgrant` from a subject with no published op, but sent malformed
bech32 — so it exercised the 400 path and never reached the check it
claimed to cover. It now sends a properly signed grant, and two further
negative cases (manipulated grantee signature, wrong `chan_bind`) close
gaps that would have passed silently.
* feat: gate the surface by role, and stop turning gaps into successes
Three fail-closed defects on the public REST surface, all of the same
family: something absent was quietly treated as something valid.
**Role and feature gating.** All twenty-five core surfaces were
registered unconditionally; only the four Blossom routes consulted
configuration. §7.5 requires a disabled function not to be served, and
§6.1 gives roles whose functions are off by default. The active set now
follows configuration, a disabled surface answers `404`, and `GET /`
advertises exactly what is served — the discovery document is derived
from the same source as registration, so the two cannot drift.
**Idempotency.** A missing `Idempotency-Key` header became `None` and
was then handed to the kernel as an empty string, which the kernel
rejects — so absent and empty collapsed into one failure. They are now
distinct: absent stays absent, and a header that is present but empty is
`400 malformed_request` at the edge. The request DTOs additionally
refuse unknown fields, nested objects included: §7.5 says the body is
exactly this object, and a field nobody reads is not a detail the server
gets to ignore.
**Closed error set.** The `ErrorInfo` consumer accepted any non-empty
`reason`, while §7.8 defines a closed set. An unrecognised reason is a
protocol violation by the kernel, not a client mistake, so it fails
loudly as `500 internal_error` rather than travelling onto the public
wire under a code no client can interpret.
**Terminal states.** `POST /v1/tx` answered `202 accepted` without
checking that the kernel returned a usable `job_id` and a valid status —
an empty id arrived at the client as success. It now refuses, matching
what the attestation path already did.
* feat: accept the delivery credential and carry it untouched
The API's role for `OutputTemplate.delivery` is deliberately small:
transport, not verifier. §6.1 puts every credential check in the kernel,
and the proto comment says so at the field. What the API owns is the
shape of the boundary, and that it owns strictly.
The REST DTO mirrors the closed tagged union of §7.5 — an invoice or a
full kind-0 profile event — under the same `deny_unknown_fields`
discipline as the rest of the body, nested objects included. An unknown
`type`, an unknown field, or a missing required member is
`400 malformed_request` at the edge, before the kernel is dialled. Form
is the API's business; content is the kernel's. Conversion to the proto
is field-for-field with no interpretation: nothing trimmed, nothing
normalised, absent and empty kept distinct.
Nothing of the credential reaches a log line or an error message. The
retention rule that binds the kernel binds the API as
strictly-never-keep: `pk0` links a recipient to its genesis nullifier on
Bitcoin, and a transport layer has no reason to remember what it was
never asked to understand. A test submits a credential and greps the
captured output for the material that must not appear.
The closed `ErrorInfo` set gains the kernel's rejection reason for
failed credential checks, matched against the node's error contract.
The carried proto is the verbatim node copy; its SHA-256 pin moves in
the same change, which is exactly the drift the pin exists to catch.
* ci: switch the hosted runner back on
The v1 rebuild is done and verified locally, so the paused workflow comes
back the way it was written to — a deletion. The PAUSED note goes and the
`pull_request:` trigger that sat commented out verbatim returns, with
`workflow_dispatch` kept for a hand-started run. Every gate above is the
one that ran before the pause.
* fix: close the concrete findings a review raised, keep the honest ones honest
A review of the diff raised findings across three areas; these are the
concrete ones (the API↔kernel verification-boundary findings are a
separate, documented follow-up).
Every `Json<T>` handler now goes through one `JsonBody<T>` extractor that
maps axum's parse, content-type and body-limit rejections onto the
closed §7.5 error surface — before, only `POST /v1/tx` did, so the rest
leaked axum's own 400/415/422. A body over the limit stays `413
payload_too_large`, not `400`; only a genuine parse or content-type
error is `400`.
A deactivated but known route answers `404 feature_disabled` with the
machine code its own docs promise, instead of a generic axum 404, while
staying out of discovery. `build_router` returns a `Result` rather than
`panic!`-ing on a store-open error, so `main` reports it like every other
startup failure. `ApiError::internal` no longer puts the internal text —
store paths, OS errors, kernel-contract detail — into the public 500
body; the cause is logged, the response is constant. Job-terminal states
run through one validator (closed status set, status↔payload
exclusivity, SSE event↔status correlation, attest requires `accepted`),
and `unauthorized↔401` / `session_expired↔410` are validated as pairs so
a contradictory kernel combination fails closed as 500.
Blossom store writes use unique temp names and atomic no-replace, and
blocking file I/O moves off the reactor via `spawn_blocking`. The
`proto_identity` sibling-node comparison is documented as local-only
(CI's real gate is pin==file), so an absent `../node` is a named skip,
not a silent green. `memo`'s absent==empty is documented as the §1.5
normalisation it is, not "unchanged".
* fix: match the kernel error envelope, stop internal-error leaks, close job/scope validation
A second review pass found real gaps in the error surface and the
job/scope validation, plus a CI build break.
The kernel returns `ErrorInfo` inside a `google.rpc.Status.details`
envelope, but the API decoded a bare `Any`, so a normal `job_not_found`
from the node failed to decode and surfaced as `500 internal_error`. The
API now decodes the `google.rpc.Status` envelope and requires exactly one
`ErrorInfo` detail, and the tests build the status with the production
encoder instead of the shape the API happened to expect. The full
`(gRPC code, reason, http status)` mapping is validated, and API-only or
job-payload-only codes are rejected as contract violations.
Two internal-error leaks are closed: a kernel `internal_error` mapped
through `ApiError::new` carried the kernel status text (which can contain
paths) onto the wire, and terminal job errors copied their `message` into
`GET /v1/jobs` and terminal SSE frames. Both now normalise to the
constant public message and log the cause operator-side; the chain-route
tests assert the neutralised body instead of the old leaking one.
Job results are validated per `kind`: `attest_balance` requires a
non-empty attestation and no transition fields, transition jobs require
their digests and carry no attestation, and terminal states must have an
empty `phase`. Pull scope is canonicalised (strictly ascending, unique
ids, non-empty interval) before any challenge/redeem RPC, and an owner-
only endpoint rejects a real GrantProof with `401` instead of a premature
`400`. Blossom uses the normative `malformed_request` code, serialises
delete/put/recovery per blob id, and no longer 500s on a concurrent
idempotent upload.
CI installs `protoc` before the Rust steps, since `kernel-proto`'s
build.rs needs it and ubuntu-latest has no protobuf-compiler by default.
* fix: accept dependency_not_final as terminal, and bound the per-blob lock map
Follow-up review found the kind-closed job validation rejected a valid
terminal job error: the node emits `dependency_not_final` as a terminal
failure, but the new check treated it as illegal. It is now in the
allowed terminal error set, with a test.
The per-blob lock map introduced last round grew without bound — entries
were never removed, so a long-running process leaked a lock per distinct
blob id. Entries are now released once no waiter holds them, so the map
stays bounded.
* feat: Data Permanence — Blossom append-only (kein DELETE, kein receipt)
Entfernt DELETE /blossom/<sha256>, retention_hold, ReplicaReceipt-Header und
Orphan-Prune auf open. Upload liefert nur noch { blob_id }; Store löscht keine
empfangenen Blobs mehr.
* feat(v1): carry the issuance creator_pubkey and re-pin the kernel proto
Add the spec-mandated creator_pubkey (Pk0) to the issuance wire object and
decode it for both token standards, so the kernel can derive the asset_id and
the genesis owner binding. Re-pin the carried kernel.proto digest to match the
new field, and pre-create the Blossom store directory owned by the runtime
user so a fresh volume mount is writable.
* fix(v1): serialize account-state send_counter as a JSON number
GET /v1/account/state emitted send_counter as a JSON string while the /jobs
endpoint and the SDK contract both treat it as a number, so clients rejected
the response. Emit it as a number (a small per-account u64 counter, precision
-safe) and update the shape test.
* feat(v1): carry the genesis-receive Pk0 and re-pin the kernel proto
Accept and forward the genesis_pubkey field on a receive request (rejected for
mint/send), and update the carried kernel.proto SHA-256 pin for the new field.
* feat(v1): bind op_pubkey to subject at bundle-entrust for grant-pull auth
A GrantProof-authorized pull could never authorize against the real running
server because AppState.subject_ops was only ever populated by tests, never
in production, so every grant pull failed 401 for lack of a subject→op_pubkey
entry.
Populate subject_ops on the authenticated /v1/bootstrap/entrust success path:
derive the operational x-only public key from the entrusted bundle (op secret
at bundle bytes 65..97) and insert it keyed by the entrusting subject. This is
the documented, legitimate population route — the node co-located with the api
holds op_sk for that subject at exactly that moment, so no new trust assumption
and no Nostr profile-resolution infrastructure is needed. The op secret is used
only to derive the public key and dropped; only the x-only pubkey is stored.
Rework the SubjectOpDirectory rationale to describe this entrust-time population,
and add a test that a successful entrust populates the directory and unblocks a
subsequent GrantProof pull that previously 401'd.
* feat(v1): clear subject_ops on bootstrap/revoke so a revoked op-key stops verifying grant proofs (§7.7 cease-use)
* feat(v1): add api-local grant revocation (POST /v1/grants/revoke{,/challenge}) with single-use nonce and grant->subject binding (§5.2)
* api: expose open token provenance — GET /v1/token/<asset_id>/provenance
Re-exposes the kernel's open Class-B token-provenance read (spec §4.6 / §7.5 /
§7.8) publicly through the API, so a token's issuer-originated terms stay
resolvable after its issuer's node is gone.
- sync the kernel proto twin with GetTokenProvenance and its request/response
messages (byte-identical to the node's), and re-pin the proto SHA-256.
- add the KernelRpc client method and the GetTokenProvenance procedure to the
kernel-error mapping (malformed_request / not_found / rate_limited /
internal_error per §7.8).
- new provenance handler projecting the kernel response to the §7.5 JSON schema
(name as raw-byte hex; v1 and v2), with an all-or-nothing v1/v2 field check.
- register the token_provenance surface as always-served and never
features-gated (§6.4): it returns provenance or 404, never feature_disabled.
- extend the §7.5 inventory, discovery, probe map, and rest-surface doc.
Tests cover the schema encoding, the REST paths (v1/v2 held, 404,
400-before-kernel-call, never-feature-gated) and the inventory/discovery
invariants; the full api suite is green.
* api: fail-closed on out-of-range token-provenance fields; sync surface doc
A review found the provenance projection forwarded two kernel fields without the
width/format validation the file applies elsewhere:
- decimals is a proto uint32 but §7.5 requires a u8; a value above 255 now fails
closed to internal_error instead of serving an out-of-schema 200.
- cap_total must be a decimal u128 string; a non-numeric or overflowing kernel
value now fails closed instead of passing through verbatim.
Adds tests for both fail-closed paths, and updates the rest-surface doc counts
and the inventory row for the token-provenance endpoint.
* test(api): cover extract, jobs, and kernel client error paths
Replace LimitedBytes expect_err with a match so the suite compiles
without Debug on the production newtype. Add unit tests for
validate_job, transition mapping, and the tonic harness; rustfmt
only reorders imports.
* test(api): cover pull helpers, chain query edges, and blossom content-type
Add fail-closed unit tests for the kernel-free pull surface
(scope normalisation, record mapping, bearer tokens, channel
binding, receipts), duplicate inscription query keys, format
and path mismatches, and require_octet_stream. Unsorted
asset_ids stay malformed.
* test(api): cover publish invariants, info fail-closed, blossom config
Add unit tests for inconsistent PublishResult shapes, unknown
info network/protocol/bootstrap, ready=true with a reason, and
require_blossom without configuration.
* test(api): cover info digest/bootstrap edges and config parse errors
Add fail-closed unit tests for missing or extra circuit digest keys,
invalid bootstrap identity fields, Feature as_str/FromStr, ConfigError
Display, Blossom limit/ops parse errors, and an odd-nibble bundle hex.
* test(api): cover job result widths, inscription query edges, empty ops
Reject short JobResult digest fields, ignore unknown or empty
inscription query pairs, and skip empty Blossom allowed-ops tokens.
* test(api): cover blossom auth framing, tags, and decimal parse
Add unit tests for Nostr header framing, kind/content checks,
duplicate or malformed t/x/expiration tags, and expiration
decimal parse errors. Trailing-space Nostr headers hit the
generic framing error after trim.
* test(api): cover ownership scope encode, decimals, and addresses
Add unit tests for grant asset-id encoding, resolved-scope
invariants, canonical u64 decimals, and Bech32m subject decode
(invalid string, wrong HRP, wrong payload length).
* test(api): cover blossom store IO errors and Config::from_env
Refuse opening a store on a regular file, fail list_root_names after
the root is renamed away, reject a corrupt uploader note, and treat
an unset process environment as MissingEnv.
* refactor(api): move process startup into the library
Keep main as a Tokio trampoline and test the fail-closed
Config::from_env path through api::run without mutating the
process environment.
* test(api): close remaining small-file coverage gaps
Cover info empty-reason, blossom upload oversize, Nostr empty
payload, proto sibling both paths, and startup bind/kernel/router
failures. Rewrite test panic arms so llvm-cov counts the taken
match. Treat a bare "Nostr" Authorization as an empty payload.
* test(api): cover blossom store IO and incomplete-pair refusals
Add filesystem tests for orphan blob/note pairs, uploader/blob
read errors, exclusive temp writes, and no-replace install.
Make store temp roots unique under parallel tests. Debug of
the entrust body keeps the operational bundle redacted.
* fix(api): close REST DTOs and align discovery docs
Reject unknown fields on pull/attest/grants/bootstrap/publish
request bodies. Map malformed ownership/grant proof hex to 401
and leave challenge.nonce as 400. Require an exact Blossom
Content-Type and two-element auth tags. Sync the 31-key
endpoint inventory and drop the leftover scaffold wording.
* test(api): cover bootstrap challenge reject arms
Empty subject, unknown action, short nonce, and a foreign
challenge domain now fail closed on POST /v1/bootstrap/challenge.
An entrust proof with an empty subject is rejected before the
kernel is called.
* test(api): cover pull, revoke, and ownership reject arms
Empty pull subjects, short/wrong challenge domains, an empty
revoke subject, and an entrusted all-0xFF op field fail closed
before or after the kernel as specified. Simple ownership
verify rejects attest domains, subject mismatch, a non-binding
nk_commit, empty public hosts, and a non-canonical Goldilocks
limb.
* test(api): cover job exclusivity, attestation JSON, and empty ids
Non-terminal job statuses must not carry result, awaiting, or
error payloads. Empty job_id is rejected before the kernel on
get, stream, sign, and cancel. Completed attest_balance JSON
includes the attestation hex.
* test(api): cover inscription page peek and grant decode rejects
Unknown nullifier state and an over-long audit path fail closed.
A full MAX_LIMIT page peeks for a successor cursor. View-grant
decode rejects the wrong HRP, a short payload, and an unknown
version. Scope intersection and an empty public-host list stay
fail-closed.
* test(api): reject empty pull session and truncated kernel views
An empty Pull session token, empty record or coin-proof bytes,
an empty account_state, a short state_head, and a half-present
last_nullifier pair all fail closed as internal_error.
* fix(api): consume grant-revoke nonces only after a valid proof
Peek the challenge before BIP-340 verification so a bad signature
does not burn the single-use nonce. A valid proof against an
expired challenge is 410 challenge_expired. Kind-0 profile events
accept extra NIP-01 fields again.
* test(api): cover grant-revoke peek and challenge_expired
get leaves a live nonce in the store until take. The 410
constructor carries challenge_expired and the caller message.
* test(api): reject a bad pull subject and a raced revoke take
An invalid pull-challenge address never reaches the kernel.
Wrong-width account head fields stay internal_error. Two
parallel grant-revoke redeems of one nonce yield one 200 and
one 401.
* test(api): cover receipt SSE happy path and stream breaks
A valid receipt becomes one receipt frame. A short coin_id or a
kernel stream error becomes a single error frame and then ends.
An empty subscription closes without a frame.
* test(api): reject malformed zkgrant asset lists and tails
Decode fails closed on a zero explicit asset count, a truncated
id list, non-ascending ids, an unknown discriminator, a short
time/nonce/signature tail, and trailing bytes after the signature.
* test(api): exercise unused CatalogKernel RPC stubs
The inscription catalog double only implements list_inscriptions.
The other KernelRpc methods stay fail-closed internal_error and
are now called once so the unused arms are covered.
* test(api): exercise unused UnreachableKernel RPC stubs
Discovery tests never call most KernelRpc methods on that double.
Each unused method now returns internal_error once so the stub
arms are covered.
* fix(api): fail-closed store IO, job-id binding, and grant-revoke expiry
Permission errors in the blossom store no longer look like absence.
Kernel job_id must match the path before any job object is forwarded.
Grant-revoke expiry is checked after a valid proof and before grant
decode, so a malformed grant cannot mask challenge_expired.
* test(api): cover store lock/IO paths, proto identity, and startup errors
Adds fail-closed tests for blossom store lock recovery and IO errors,
sibling proto-pin checks, and startup config/bind failures. Drops the
racy complete-pair and global-shutdown tests that hung or were not
portable on macOS.
* test(api): cover job validate, sign/cancel, and SSE projection arms
Adds a handler double for unused kernel RPCs and unit tests for
idempotency-key parsing, sign/cancel, job JSON projection, and
fail-closed form errors. Drops an Unpin-incompatible SSE drain test.
* test(api): cover remaining ownership, route, and store error arms
Add unit tests for unauthorized/malformed ownership paths, last-nullifier
width, disabled-route panics, and explorer-only Blossom advertisement.
Mark proven-unreachable TOCTOU arms with coverage_nightly, fix clippy
-D warnings, and retry the flaky note-install race.
* docs(api): count three Blossom discovery keys and split API-local rows
Absence of ZKCOINS_BLOSSOM_STORE leaves blossom_get/head/upload unadvertised,
not four keys. Origin-local GET / and GET /health stay distinct from the
kernel-less grant-revoke pair.
* fix(api): validate bootstrap op keys before the kernel dial
Reject an invalid secp256k1 op secret with 400 and no RPC. Map a
pre-epoch clock on Blossom upload to an internal error instead of
panic. Refresh operator comments onto startup.rs and drop unused wget.
* docs(api): align the ServedSurface gate table with is_active
token_provenance is always-on. Blossom GET/HEAD need the store and
explorer; upload needs the store and wallet or explorer.
* docs(api): document Blossom role gates and drop served LNURL
GET/HEAD need the store and explorer; upload needs the store and wallet
or explorer. A store without a role is a feature_disabled stub. The
opening line now names the REST surface that actually ships.
* docs(api): stop claiming a served LNURL HTTP surface
LNURL mappings stay a database concern. The process ships REST on the
kernel RPC; the opening docs now match that.
* docs(api): count token provenance in the §7.5 total
The closed table is 33 routes. The SHA-256 proto pin is the CI identity;
the sibling node compare stays optional and local.
* docs(api): name API-local state and drop the rate-limiter claim
Crate docs now distinguish value-bearing kernel state from local
directories. The kernel module is the client only. Rate limits stay
Kernel ErrorInfo, not an API limiter.
* fix(api): send empty pull action and serialize subject-op updates
OpenPullChallenge uses the proto empty-string pull discriminator.
Entrust and revoke share a per-subject lock through the cache write.
Blossom now fail-closes on directory sync before acknowledging a put.
* fix(api): hold the subject lock across grant-pull verify
Re-read the published op after dropping the lock and refuse a
concurrently revoked grant immediately before the kernel Pull.
* fix(api): refuse unknown grant subjects before allocating locks
Grant-pull probes subject_ops before mutex_for so unknown subjects
cannot grow the lock map. Decode failures stay 401. Grant-revoke
challenges evict expired entries and refuse above 4096 outstanding.
* docs(api): pin sha2 and translate remaining German rustdoc
House rule requires an exact sha2 pin. Remaining handler rustdoc is
English so the public tree matches the rest of the crate.
* docs(api): translate Dockerfile operator comments to English
Operator comments now match the crate language. The bookworm
protobuf-compiler pin comment matches the installed package.
* chore(api): ignore dotenv variants the image already excludes
Keep .env.example committable. Local env files must not be trackable.
* fix(api): fsync the blossom root before every complete-pair Ok
Names on disk are not durable until the store-root sync succeeds.
Retries after a failed sync now fail closed instead of returning Ok.
* docs(api): state that pull accepts GrantProof as well as ownership
The status table now matches the closed capability row and the
handler. Unpublished subjects still fail closed with 401.
* docs(api): describe lazy kernel dial and local PR workflow
Operator docs no longer claim an eager kernel dial before serve.
CONTRIBUTING now names the develop target and this repo's CI job.
* test(api): widen the blossom note-tmp watcher window
The CI runner finished both hard_links before the watcher saw the
final blob. Delete the note temp as soon as it appears instead.
* 019ffcd1 - Allow any verified blossom op on test nodes (#3)
* feat(api): allow any verified blossom op when ALLOWED_OPS is *
A dedicated test node cannot pre-list every fixture wallet. The sole
token * accepts any kind-24242 that already verifies; mixing * with
hex keys is a start error. Empty still 403s.
* style(api): rustfmt blossom allow-any ACL condition
* docs(api): document blossom * allow-any and satisfy clippy
The rest-surface table and image comments still described only
hex keys. Record the sole-* token and use BTreeSet::contains so
-D warnings stays green.
* test(api): cover blossom allow-any upload ACL
Config empty-ops stays deny-all. A verified unlisted kind-24242
uploads under allow_any and is still 401 without Authorization.
EN:
Dedicated test nodes cannot pre-list every fixture wallet. A sole
*token onZKCOINS_BLOSSOM_ALLOWED_OPSaccepts any kind-24242 that already verifies.DE:
Dedizierte Test-Nodes können nicht jedes Fixture-Wallet vorab listen. Ein alleinstehendes
*inZKCOINS_BLOSSOM_ALLOWED_OPSakzeptiert jedes bereits verifizierte Kind-24242.Details
Empty allow-list still 403s. Mixing
*with hex keys is a start error.