refactor(host_primitives): single home for the Turnkey wire envelope - #446
Conversation
parser_gateway kept the Turnkey request/response types inline while the x402 branch had grown its own copy in host_primitives::turnkey. Two definitions of one wire contract means every envelope change (bootProof in #337, intermediateOutput in #414) has to be made twice and kept in sync by hand. Move them to host_primitives::turnkey as the union of both, and make bootProof an injected value instead of a hardcoded mock, so the enclave pivot can supply a real one while the gateway keeps its local-dev mock. No wire change: the gateway still emits the same mock constants, and the wire-shape tests move with the types. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Centralizes the Turnkey wire envelope in host_primitives while preserving the gateway’s existing JSON contract.
Changes:
- Adds shared request, response, boot-proof, and error-envelope types.
- Updates
parser_gatewayto use shared types with its local mock boot proof. - Adds wire-shape and serialization tests.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/parser/gateway/src/main.rs |
Replaces local envelope types with shared ones. |
src/host_primitives/src/turnkey.rs |
Defines the shared Turnkey envelope. |
src/host_primitives/src/lib.rs |
Exports the new module. |
src/host_primitives/Cargo.toml |
Adds serialization dependencies. |
src/Cargo.lock |
Records dependency updates. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…elper Moving the types into host_primitives left construction behind: error_response lived in the shared crate, but parse_handler still assembled the success envelope field by field in the gateway. That splits one wire contract across two places again, in the same way the duplicated type definitions did, and it is the copy the enclave pivot would have had to reproduce a third time. Add success_response next to error_response and have the gateway call it. Three gateway tests that now duplicate coverage living in host_primitives::turnkey move out instead of being maintained in both places: the six-key bootProof assertion, the empty intermediate_output omission check, and the Solana chain-metadata discriminator test. The gateway keeps the assertions that are about its own mock constants and its top-level wire shape. No wire change. The emitted JSON is byte-identical, verified against the pre-refactor struct definitions. Co-Authored-By: Claude <noreply@anthropic.com>
…trip intermediate_output carries skip_serializing_if, so a response without one omits the key entirely. That is deliberate and keeps existing consumers byte-identical. But String has no serde default, so the omitted key made deserialization fail: the envelope could be written and not read back. Nothing in tree deserializes a response today, which is why no test caught it. parser_http_server will, and the shape it will be handed most often is exactly the one that fails. Add serde(default) next to the existing skip_serializing_if, plus a round-trip test over the error envelope, the shape every current caller produces. Serde defaults apply on the way in only, so serialization output is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
shahan-khatchadourian-anchorage
left a comment
There was a problem hiding this comment.
Right call: one home, boot_proof as a parameter instead of a fork. Verified locally — 5 + 11 tests pass, clippy and fmt --check clean, and no-wire-change holds.
Good: mock stays local to the gateway; parity was byte-compared rather than asserted; test relocation accounted for (13 -> 11, 4 -> 5); the untagged hazard docstring and its test moved with the type; the x402 rename_all trap is covered by the six-key test.
Notes, all non-blocking:
-
Take the offer on the client-direction derives. Drop
Serializeon request types,Deserializeon response types. Worth noting none of this tripsdead_code—pubitems in a lib crate never do, so clippy passing says nothing about reachability here.- Serializing a request emits
"signature":null,"abiType":null,...and mixes casing (unsigned_payload+networkId). Not a regression — the read path has always accepted that shape (README.md:57) — butSerializeis now what emits it, and nothing pins the request wire shape the way the six-key test pins the response. - No inverse of
From<ChainMetadataInput> for ChainMetadata, so a caller holding the proto type can't reach the serialize path. - Deserialize is stricter than serialize — a response missing one
bootProoffield fails onmissing field deploymentLabel. The round-trip test only reads back what the same type wrote.
I measured the trim (compiles, 4 + 11 pass): 266 -> 242 source lines, but expanded 2251 -> 883 and rlib 364KB -> 165KB. Side effect: commit 3 becomes moot, since
defaultonintermediate_outputgoes inert withoutDeserialize. Keep the attribute anyway; PR drops to two commits. The comment atturnkey.rs:26-28is aspirational perCLAUDE.md— belongs in the PR body.If you'd rather keep the derives, the three things that would make them real: a request wire-shape test, a
TryFrom<ChainMetadata> for ChainMetadataInput, and a call on null-vs-omit. - Serializing a request emits
-
defaultonchain_metadatais a no-op — serde already defaultsOption<T>toNone(checked). Inconsistent witherror/signature, which carry onlyskip_serializing_if.rename_allonChainMetadataInputis also dead (both variants explicitly renamed), but that's pre-existing. -
The enclave picked up serde derives it doesn't use.
parser_applinkshost_primitives, sogeneratedgoes fromtonic_typestoserde, serde_derive, tonic_types.images/parser_app/Containerfile:16-18reasons about attestation surface and the vsock image has no HTTP surface; #450 enables the feature itself. A non-defaultturnkeyfeature covers it. Survives the trim in (1). Minor:health_check'sdefault-features = falseonhost_primitivesis a no-op — no features declared. -
The duplication that hurts didn't move. #449:1314 and #450:970 hand-build the success envelope field by field, so
success_responsehas one caller and the two PRs motivating it bypass it. The triplicated part is proto -> wire:Chain::from_str_name, theSignatureScheme as i32match, the base64 encode, theparsed_transaction/payloadunwrapping.success_response_from_proto(...)would collapse all three. -
#304 is the third envelope copy, still open on
spec/x402-gated-http-api— worth confirming it rebases onto this rather than reconciling definitions.
…esponse The pivot was written against host_primitives::turnkey as of the fork point, before #446 grew a shared success_response helper. Rebasing onto main left handle_parse assembling the wrapper field by field, which is the second copy of the wire contract that #446 existed to remove. No wire change: success_response fills the same three fields and sets error: None, so the emitted JSON is byte-identical. The bootProof six-key assertions in host_primitives, parser_http_server and the http_server integration test all still pass. Co-Authored-By: Claude <noreply@anthropic.com>
…esponse The pivot was written against host_primitives::turnkey as of the fork point, before #446 grew a shared success_response helper. Rebasing onto main left handle_parse assembling the wrapper field by field, which is the second copy of the wire contract that #446 existed to remove. No wire change: success_response fills the same three fields and sets error: None, so the emitted JSON is byte-identical. The bootProof six-key assertions in host_primitives, parser_http_server and the http_server integration test all still pass. Co-Authored-By: Claude <noreply@anthropic.com>
…esponse The pivot was written against host_primitives::turnkey as of the fork point, before #446 grew a shared success_response helper. Rebasing onto main left handle_parse assembling the wrapper field by field, which is the second copy of the wire contract that #446 existed to remove. No wire change: success_response fills the same three fields and sets error: None, so the emitted JSON is byte-identical. The bootProof six-key assertions in host_primitives, parser_http_server and the http_server integration test all still pass. Co-Authored-By: Claude <noreply@anthropic.com>
…#449) ## Why Host side of the x402 work, ported off the unmerged PR #304 (stacked on a now-dead branch, 132 commits behind main) onto current main so it can actually be reviewed. Rebased onto #446 (merged), reusing the shared Turnkey wire types (`success_response` etc.) from `host_primitives::turnkey` instead of a second copy. ## What - Splits `parser_gateway`'s single `main.rs` into modules: `lib.rs`, `state.rs`, `auth.rs`, `attestation.rs`, `x402_config.rs`, `handlers/{mod,health,parse}.rs`. - x402 price-tag configuration and middleware builder. - Optional shared-bearer-token gate on the gateway hop. - Demo TVC response-attestation verifier: checks the enclave's ephemeral-key signature against a pinned pubkey, and binds it to the specific response being forwarded (not just "the enclave signed something at some point"). - Moves the crate to axum 0.8, already the workspace norm (`parser/cli` runs it alongside 0.6.20 in `metrics`). **Deliberately left out** (needs `host_primitives::payment_marker` from #448, not in this base): `signing.rs`/`gateway_keygen`, `parse_tvc.rs`/`tvc_probe`, and the chain-to-402-network derivation living in `parse_tvc.rs`. Net effect: this gateway can verify and settle x402, but can't yet *sign* a `VerifiedPaymentMarker`, so the trust pair completes in the follow-up PR. ### Review fixes folded in - Signature verification recomputes the digest from the forwarded payload instead of trusting the wire-carried `Signature.message`, closing a replay path (a captured valid signature could otherwise be paired with a forged payload). - The request/response digest-binding check now runs unconditionally, not only when a pinned attestation verifier is configured. - x402 config rejects silent mis-pricing at load: non-USDC assets, zero/sub-atomic prices, a zero facilitator timeout. - Bearer gate: fixed fail-open on a non-UTF-8 token, a non-ASCII token booting clean then 401'ing every request, and case-sensitive scheme matching. - Facilitator startup probe now resolves `/supported` the way x402-axum actually does (RFC 3986 relative join, not string concatenation). - Deduped the "unset vs non-UTF-8 env var" and bounded-file-read idioms, previously hand-copied 2-3x across `auth.rs` / `attestation.rs` / `x402_config.rs`. - IPv6 loopback check now matches `Url::host_str()`'s bracketed form (`[::1]`), not just `::1`. ## Test evidence ``` make -C src build -> clean make -C src test -> 0 failed across the workspace cargo test -p parser_gateway -> 84 passed, 0 failed (11 on main before this PR) cargo fmt --check -> clean make -C src lint -> clean (clippy --all-targets -D warnings) ``` Wire shape is unchanged. Boot-proof regression tests (`mock_boot_proof_matches_production_wire_shape`, `error_response_carries_mock_boot_proof`) survive the module split with byte-for-byte identical mock values. ## Rollback Revert the commits. The gateway only runs in non-TEE local dev and CI, never a real enclave, so a revert can't touch production. ## Linear PRS-581. Follows #446 (merged). Supersedes the gateway portion of #304. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…esponse The pivot was written against host_primitives::turnkey as of the fork point, before #446 grew a shared success_response helper. Rebasing onto main left handle_parse assembling the wrapper field by field, which is the second copy of the wire contract that #446 existed to remove. No wire change: success_response fills the same three fields and sets error: None, so the emitted JSON is byte-identical. The bootProof six-key assertions in host_primitives, parser_http_server and the http_server integration test all still pass. Co-Authored-By: Claude <noreply@anthropic.com>
Why
Two host binaries have to serve the same Turnkey JSON envelope to the same client.
parser_gateway: REST in front, gRPC toparser_grpc_serverbehind. Non-TEE local dev and CI, so it emits a mockbootProof.parser_http_server(feat(parser_http_server): HTTP+JSON pivot for TVC public ingress #450): HTTP+JSON inside the enclave, callingparser_appin process, no gRPC hop. This is what gets deployed to the TVC, because Cloudflare in front ofapp-<uuid>.turnkey.cloudrejects gRPC with 403. It emits a real attestedbootProof.Different transports, different trust levels, one wire contract. The Go
visualsign-turnkeyclientand the wallet integrators behind it cannot tell the two apart, and must not be able to.Two features also land in that envelope during PRS-581:
bootProof(#337) andintermediateOutput(#414). If the definition stays inline inparser_gateway, the pivot copies it, and every envelope change from here on gets made twice and kept in sync by hand. The unmerged x402 branch had already grown its own third copy.So: one home, two importers, and the
bootProofdifference becomes a parameter instead of a fork.Downstream, #450, #451 and #452 build on
parser_http_server; #449 builds onparser_gateway. Both sides import the envelope from here, which is also what shrinks the PR #304 rebase from "reconcile two envelope definitions" to "add a module".What
host_primitives::turnkeybecomes the single home for the Turnkey request/response envelope, as the union of both existing definitions.bootProofis now an injected value rather than a hardcoded mock:error_response(msg, boot_proof).parser_gatewaykeeps its stable local-dev mock through a local shim; the enclave pivot supplies a real attested one.success_responsesits next toerror_response, soparse_handlerno longer assembles the success envelope field by field in the gateway.parser_gatewayimports the shared types and keeps the fourMOCK_BOOT_PROOF_*constants and the tests that are about its own mock and top-level wire shape. Three tests that would have duplicated coverage now living inhost_primitives::turnkeymoved there instead of being maintained in both places.intermediate_outputgainedserde(default)alongside its existingskip_serializing_if. Without it the omitted key serialized fine but failed to deserialize, so a response could be written and not read back. Nothing in tree deserializes a response today (both binaries only emit one), which is why no test caught it. The client-direction derives are there so the envelope is symmetric for the out-of-tree clients that do read it, and the new round-trip test is what keeps it that way. Happy to drop those derives and thedefaultuntil something in tree needs them, if you'd rather not carry them.No wire change. The gateway's JSON output is byte-identical.
Test evidence
The gateway test count moves from 13 to 11 because three tests relocated to
host_primitives::turnkey, where the types they cover now live: the six-keybootProofassertion (boot_proof_wire_shape_is_exactly_six_camel_case_keys), the emptyintermediateOutputomission check, and the Solana chain-metadata discriminator test.host_primitivesgoes 4 to 5 with the new round-trip test. No coverage was dropped.The two regression tests that matter both still pass with unchanged constant values:
mock_boot_proof_matches_production_wire_shape,error_response_carries_mock_boot_proof.The "no wire change" claim was checked rather than assumed: a throwaway test reconstructed the pre-refactor struct definitions verbatim from base and byte-compared
serde_jsonoutput against the newhost_primitives::turnkeytypes across success, error, signature-present, and intermediate-output-present cases. All identical.One trap worth recording: the x402 branch's
TurnkeyResponseWrapperhad norename_all = "camelCase", which the gateway's inline struct did have. Addingboot_proofwithout it would have serialized asboot_proofinstead ofbootProofand silently broken the wallet contract. The six-key wire-shape test catches it.Rollback
Revert the commits. No deploy, no migration, no wire change, so a revert restores the previous state exactly.
Linear
PRS-581
Stack position: base of the PRS-581 stack. #337, #414.
🤖 Generated with Claude Code