b9e063fe - Build the v1 public API surface (§7.5) - #2
Draft
TaprootFreak wants to merge 23 commits into
Draft
Conversation
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.
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.
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.
…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.
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.
`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.
…e, 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
…s 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".
…e 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.
…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.
Contributor
Author
Review statusThis PR has had five independent review passes (conformance and logic). Over the passes it gained It stays in Draft for these named follow-ups:
|
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.
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Builds the API process into the sole public surface of spec-v1.2: the versioned
/v1/REST contract over thekernel.v1gRPC client. It owns a non-value-bearing database, touches neither the kernel's store nor Bitcoin, and proves nothing itself. Hosted CI (added here) was paused for the rebuild and is switched back on in the final commit.What this delivers
GET /advertises exactly the surfaces served, derived from one source so the two cannot drift.GrantProoffully verified in the spec's order; the effective scope is the intersection of grant and request — never widened, never unbounded.401vs410kept distinct.404, not served; the active set follows configuration, not a constant.400; DTOs refuse unknown fields; the closedErrorInfoset is enforced (an unknown kernel reason fails loudly).POST /v1/txno longer answers202for an empty job id or an unknown status.Verification
Locally green: fmt, clippy (
-D warnings), the full test suite (240+). The carriedkernel.protois SHA-256-pinned against the node's copy so a drift is caught. Hosted CI is restored in the last commit.