From 33ade75e77d2e99c7e481ade185c6809e06f42c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Stielau?= Date: Tue, 12 May 2026 23:13:40 +0000 Subject: [PATCH 01/11] docs(spec): x402 v2 gated HTTP API on parser_gateway Adds the design for a new POST /visualsign/api/v2/parse route with x402-axum middleware, a mock_facilitator dev binary, and env+profile config (local/payai/custom) with multi-tag support. Existing Turnkey v1 endpoint and /health remain unprotected. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-12-x402-gated-http-api-design.md | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-12-x402-gated-http-api-design.md diff --git a/docs/superpowers/specs/2026-05-12-x402-gated-http-api-design.md b/docs/superpowers/specs/2026-05-12-x402-gated-http-api-design.md new file mode 100644 index 00000000..f69977da --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-x402-gated-http-api-design.md @@ -0,0 +1,221 @@ +# x402-gated HTTP API on parser_gateway + +**Status:** Approved (design) +**Date:** 2026-05-12 +**Owner:** André Stielau + +## Problem + +We want a public HTTP endpoint on the parser that monetizes per-call usage via the x402 v2 payment protocol, while preserving the existing Turnkey deployment path (which must not require payment). The endpoint must work in three deployment shapes: + +- **Local dev** — points at a bundled mock facilitator so the full request/response flow is exercisable without a real wallet or settlement. +- **PayAI** — points at the public PayAI facilitator (`https://facilitator.payai.network`), Solana + EVM, no API key. +- **Custom / other** — facilitator URL, network, asset, recipient, price all overridable via env vars. + +Price-per-call must be configurable, defaulting to *very low* values in `local` and `payai` profiles so dev and pilot deployments don't accidentally over-charge. + +## Goals + +- New `POST /visualsign/api/v2/parse` route with `x402-axum` middleware enforcing payment. +- `GET /health` and `POST /visualsign/api/v1/parse` (existing Turnkey route) remain open and behaviorally unchanged. +- Single source of truth for x402 config via env vars + named profiles. +- Multi-tag config support (`Vec`) even though typical deploys use one tag. +- Bundled `mock_facilitator` binary for local dev and integration tests; **never** built into TDX/Turnkey prod images. +- Fail-fast at startup on any config error; no silently-broken endpoints at request time. + +## Non-goals + +- No changes to `parser_app` (the in-enclave gRPC service). All payment gating sits in the gateway, in front of gRPC. +- No end-to-end test with a real wallet signing real x402 v2 payments. Out of scope; upstream `x402-rs` covers that. +- No support for facilitator-side smart-wallet sig schemes beyond what `x402-axum` provides by default. We layer the middleware as documented; protocol-level additions come for free with upstream versions. +- No Turnkey-signed bypass header for the v2 route. Turnkey continues to use v1. + +## Architecture + +``` + ┌──────────────────────────────────────────┐ + │ parser_gateway (axum 0.8) │ + │ │ + client ─POST──▶ │ GET /health open │ + │ POST /visualsign/api/v1/parse open │ (Turnkey-compatible, unchanged) + │ POST /visualsign/api/v2/parse ┌──x402──┤ + │ │ layer │ + └─────────────────────────────────┼────────┘ + │ gRPC ParserService.Parse │ verify/settle (HTTP) + ▼ ▼ + ┌──────────────┐ ┌──────────────────────────────┐ + │ parser_app │ │ facilitator │ + │ (TDX/vsock) │ │ local : mock_facilitator │ + │ or grpc-srv │ │ payai : facilitator.payai… │ + └──────────────┘ │ custom : X402_FACILITATOR_URL│ + └──────────────────────────────┘ +``` + +Two binaries change; one is new. Everything stays in the existing Cargo workspace. + +- **`parser_gateway`** — axum 0.6 → 0.8 upgrade. New `/visualsign/api/v2/parse` route layered with `x402-axum`'s `X402Middleware`. Existing routes untouched. +- **`mock_facilitator`** — new tiny crate at `src/parser/mock-facilitator/`. Implements the x402 facilitator HTTP surface (`/verify`, `/settle`, `/supported`) and approves everything with a synthetic tx hash. Bundled into the local Containerfile only. +- **`parser_app`** — no changes. + +## Components + +### parser_gateway — extended + +File layout (small refactor; current `main.rs` is 300 LoC and mixes concerns): + +``` +src/parser/gateway/src/ + main.rs # bin entry, env reads, router assembly, shutdown + state.rs # AppState { grpc_client, health_client } + handlers/ + health.rs # GET /health (extracted as-is) + parse.rs # shared parse logic (Turnkey envelope → gRPC → Turnkey envelope) + turnkey.rs # TurnkeyRequestWrapper / TurnkeyResponseWrapper structs + x402_config.rs # X402Profile enum + X402Config loader (env → struct) +``` + +The v1 and v2 routes share the **same** handler body (`handlers::parse::handle`). Both take the Turnkey envelope and emit the Turnkey response shape. The only difference is that v2 is layered with `x402-axum`'s `X402Middleware`. The middleware exposes a chainable `.with_price_tag(...)` builder; for an N-element `price_tags` config, we call it N times during router construction so all tags appear in the 402 `accepts` array. v1 has no x402 layer. + +### x402_config.rs — environment → middleware + +```rust +pub enum X402Profile { Local, PayAi, Custom } + +pub struct X402Config { + pub facilitator_url: Url, + pub facilitator_timeout: Duration, + pub protocol_version: ProtocolVersion, // V2 default + pub price_tags: Vec, // 1..N, non-empty +} + +pub struct PriceTagConfig { + pub network: Network, // base-sepolia, base, solana, ... + pub asset: AssetSpec, // USDC (resolves to per-network address) + pub price_usd: Decimal, + pub pay_to: PayToAddress, // EVM Address or Solana Pubkey + pub scheme: Scheme, // Exact (default) | Upto +} + +impl X402Config { + pub fn from_env() -> Result { /* per matrix below */ } +} +``` + +Profile chooses a seeded `price_tags` of length 1; individual env vars or the JSON override mutate from there. + +### mock_facilitator — new crate + +`src/parser/mock-facilitator/` — axum 0.8 binary, ~100 LoC: + +- `POST /verify` → `{ "isValid": true, "payer": }` +- `POST /settle` → `{ "success": true, "transaction": "0xmock", "network": , "payer": }` +- `GET /supported` → enumerates the local profile's network + asset +- Listens on `MOCK_FACILITATOR_PORT` (default `8090`) +- No real crypto; the gateway's x402-axum middleware does protocol-level header parsing and trusts the facilitator verdict. + +### Container changes + +- **`images/parser_app/Containerfile`** (local dev image): adds `mock_facilitator` to the build stage; entrypoint script starts it on `:8090` alongside existing processes; image defaults `X402_PROFILE=local` and `X402_FACILITATOR_URL=http://127.0.0.1:8090`. +- **TDX / Turnkey prod**: unchanged. `mock_facilitator` is neither built nor shipped. + +## Config matrix + +| Env var | Required? | `local` default | `payai` default | `custom` default | +|---|---|---|---|---| +| `X402_PROFILE` | no — defaults to `local` | `local` | set explicitly to `payai` | set explicitly to `custom` | +| `X402_FACILITATOR_URL` | no in `local`/`payai` | `http://127.0.0.1:8090` | `https://facilitator.payai.network` | **required** | +| `X402_FACILITATOR_TIMEOUT_SECS` | no | `5` | `5` | `5` | +| `X402_PROTOCOL_VERSION` | no | `v2` | `v2` | `v2` | +| `X402_PAYTO` | yes in `payai`/`custom`; optional in `local` | `0x000000000000000000000000000000000000dEaD` | **required** | **required** | +| `X402_PRICE_TAGS_JSON` | no | unset | unset | unset | + +When `X402_PRICE_TAGS_JSON` is unset, the seeded `price_tags` from the profile is used (with `X402_PAYTO` filled in for `payai`/`custom`). When set, it discards the seeded tag and replaces with the parsed list; example: + +``` +X402_PRICE_TAGS_JSON='[ + { "network":"base", "asset":"USDC", "payTo":"0xabc...", "priceUsd":"0.001", "scheme":"exact" }, + { "network":"solana", "asset":"USDC", "payTo":"So1...", "priceUsd":"0.001", "scheme":"exact" } +]' +``` + +Seeded defaults per profile: + +| Profile | seeded `price_tags[0]` | +|---|---| +| `local` | `{ network: base-sepolia, asset: USDC, priceUsd: 0.0001, payTo: $X402_PAYTO or 0x000…dEaD, scheme: exact }` | +| `payai` | `{ network: base, asset: USDC, priceUsd: 0.001, payTo: $X402_PAYTO (required), scheme: exact }` | +| `custom`| no seed — `X402_PRICE_TAGS_JSON` required | + +## Data flow + +``` +1. client ──POST /visualsign/api/v2/parse (no X-PAYMENT)──▶ gateway +2. gateway x402 layer ──▶ 402 Payment Required + body: { x402Version: 2, accepts: [, , ...] } +3. client signs payment authorization +4. client ──POST /visualsign/api/v2/parse + X-PAYMENT: ──▶ gateway +5. gateway x402 layer ──POST /verify──▶ facilitator +6. facilitator ──{ isValid: true, payer }──▶ gateway +7. gateway parse handler ──gRPC ParserService.Parse──▶ parser_app (vsock/local) +8. parser_app ──ParseResponse──▶ gateway +9. gateway x402 layer ──POST /settle──▶ facilitator (post-handler, only on 2xx) +10. facilitator ──{ success: true, transaction }──▶ gateway +11. gateway ──200 + Turnkey envelope + X-PAYMENT-RESPONSE header──▶ client +``` + +Notes: + +- **Settle-after-success:** x402-axum settles only when the wrapped handler returned 2xx. A failed parse (bad chain, malformed payload) is 4xx and we do **not** settle — the payer is not charged for a broken request. Default x402-axum behavior; we do not override it. +- **Body limit:** the existing `DefaultBodyLimit::max(GRPC_MAX_RECV_MSG_SIZE)` layer stays applied router-wide so v2 inherits it. +- **Timeouts:** parse stays at 30s; facilitator verify/settle gets its own 5s timeout (configurable via `X402_FACILITATOR_TIMEOUT_SECS`). + +## Error handling + +| HTTP status | When | Body | +|---|---|---| +| `200` | parse + payment settled | Turnkey envelope | +| `400` | malformed JSON, unknown chain, bad payload | Turnkey envelope with `error` field (same shape as v1) | +| `402` | missing/invalid `X-PAYMENT` header | x402-axum default: `{ x402Version: 2, accepts: [...], error: "..." }` | +| `403` | facilitator says `isValid: false` | x402-axum default: `{ ..., error: "verification failed" }` | +| `500` | gRPC unavailable, missing fields in response | `{ error: "internal error" }` (no gRPC detail leak; matches v1) | +| `502` | facilitator unreachable or transport error | `{ error: "facilitator unavailable" }` | +| `504` | parse > 30s OR facilitator > 5s | `{ error: "request timed out" }` or `{ error: "facilitator timed out" }` | + +Three concrete rules for the gateway code: + +- **Don't settle on handler error.** Rely on x402-axum's default `settle_on_success` behavior. Verify in the integration test. +- **Don't leak gRPC `Status` messages.** The existing v1 handler maps `InvalidArgument → 400`, `NotFound → 404`, everything else to a generic 500 with `eprintln!` of the real error. The shared `handlers::parse::handle` keeps that mapping; v2 inherits it. +- **Fail-fast on startup, not per-request.** Any config error (missing `X402_PAYTO` for `payai`, malformed `X402_PRICE_TAGS_JSON`, invalid network/asset/address, unreachable facilitator on the startup probe) panics the binary at startup with a clear message. The startup probe is a single `GET /supported` request to the configured facilitator URL with the same 5s timeout; if it fails the gateway does not bind its listening port. No silently-broken v2 endpoint. + +## Testing + +**Unit tests** (`gateway/src/x402_config.rs`): +- `from_env` parses each profile correctly with no overrides. +- `X402_PAYTO` override fills in the seeded tag for `payai`/`custom`. +- `X402_PRICE_TAGS_JSON` override replaces the seeded list and rejects malformed input. +- Missing required fields per profile return a clear `ConfigError` (don't panic at this layer; the binary panics, the config does not). + +**Mock facilitator unit tests** (`mock-facilitator/`): +- `/verify`, `/settle`, `/supported` return the expected shapes for a synthetic request. + +**Gateway-level integration test** (extend `src/integration/tests/`): +- Spin up `mock_facilitator` + `parser_grpc_server` + `parser_gateway`. +- **Path 1:** `POST /visualsign/api/v2/parse` with no `X-PAYMENT` → asserts `402`, asserts body contains `accepts: [...]` with the local-profile defaults. +- **Path 2:** same request with a stub `X-PAYMENT` header → asserts `200`, correct Turnkey envelope, `X-PAYMENT-RESPONSE` header present. +- **Path 3:** same request with a malformed payload → asserts `400` AND asserts the mock facilitator never received `/settle`. +- **Path 4:** `POST /visualsign/api/v1/parse` with no `X-PAYMENT` → asserts `200`. Proves v1 stays unprotected. +- **Path 5:** `GET /health` with no `X-PAYMENT` → asserts `200`. Proves health stays unprotected. + +**Manual smoke** (documented, not automated): +- Build the local Containerfile, `docker run`, curl `/health` and `/visualsign/api/v2/parse` from outside the container. + +**Explicitly out of scope for this PR:** +- End-to-end test with a real wallet signing real x402 v2 payments. +- Load test or pricing math test. +- Test that exercises payai's real facilitator (deploy-time smoke, not CI). + +## Risks / open questions + +- **axum 0.6 → 0.8 upgrade**: the existing Turnkey handler uses standard extractors (`Json`, `State`, `DefaultBodyLimit`) and standard handler signatures. Changes are expected to be mechanical, but the upgrade is bundled into this PR and may surface a transitive dep conflict (e.g., `tonic` ↔ `hyper`/`tower` versions). Mitigation: validate `make -C src build` + `make -C src test` early; if blocked, fall back to hand-rolled middleware (one of the rejected options). +- **PayAI facilitator schema drift**: PayAI implements the x402 spec but the schema may drift from upstream `x402-types`. Mitigation: pin `x402-axum` to a known-good version; bump deliberately. +- **Solana payTo address shape**: `PayToAddress` needs to accept both EVM (20 bytes hex) and Solana (32 bytes base58). The `x402-types` crate should already model this; if not, our wrapper enum handles the discrimination at parse time. From 1deb3d080d4ed54a410e5e1a7879981865d7c807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Stielau?= Date: Wed, 13 May 2026 11:31:43 +0000 Subject: [PATCH 02/11] feat: add health-check and parse handlers for gRPC backend - Implemented a health-check handler in `health.rs` that proxies to the gRPC backend's health service, returning the service status. - Created a shared parse handler in `parse.rs` for processing requests from both v1 and v2 parse endpoints, with appropriate error handling and response formatting. - Introduced a new module `handlers` to organize the health and parse handlers. - Established application state management in `state.rs` to hold gRPC clients. - Added `turnkey.rs` for handling request/response envelopes specific to parse endpoints. - Configured x402 settings in `x402_config.rs` to load from environment variables, including profile management and price tag configurations. - Developed a mock facilitator service in `mock-facilitator` for testing purposes, including endpoints for verification and settlement. - Included tests for the new functionality to ensure reliability and correctness. --- Makefile | 5 + .../2026-05-12-x402-gated-http-api-design.md | 221 -------- images/mock_facilitator/Containerfile | 31 ++ images/parser_gateway/Containerfile | 2 + scripts/x402-demo.sh | 343 ++++++++++++ src/Cargo.lock | 274 +++++++++- src/Cargo.toml | 1 + src/integration/Cargo.toml | 2 + src/integration/tests/x402_gateway_test.rs | 378 +++++++++++++ src/parser/gateway/Cargo.toml | 38 +- src/parser/gateway/src/handlers/health.rs | 52 ++ src/parser/gateway/src/handlers/mod.rs | 2 + src/parser/gateway/src/handlers/parse.rs | 120 +++++ src/parser/gateway/src/lib.rs | 11 + src/parser/gateway/src/main.rs | 363 ++----------- src/parser/gateway/src/state.rs | 13 + src/parser/gateway/src/turnkey.rs | 145 +++++ src/parser/gateway/src/x402_config.rs | 501 ++++++++++++++++++ src/parser/mock-facilitator/Cargo.toml | 26 + src/parser/mock-facilitator/build.rs | 7 + src/parser/mock-facilitator/src/lib.rs | 196 +++++++ src/parser/mock-facilitator/src/main.rs | 39 ++ 22 files changed, 2210 insertions(+), 560 deletions(-) delete mode 100644 docs/superpowers/specs/2026-05-12-x402-gated-http-api-design.md create mode 100644 images/mock_facilitator/Containerfile create mode 100755 scripts/x402-demo.sh create mode 100644 src/integration/tests/x402_gateway_test.rs create mode 100644 src/parser/gateway/src/handlers/health.rs create mode 100644 src/parser/gateway/src/handlers/mod.rs create mode 100644 src/parser/gateway/src/handlers/parse.rs create mode 100644 src/parser/gateway/src/lib.rs create mode 100644 src/parser/gateway/src/state.rs create mode 100644 src/parser/gateway/src/turnkey.rs create mode 100644 src/parser/gateway/src/x402_config.rs create mode 100644 src/parser/mock-facilitator/Cargo.toml create mode 100644 src/parser/mock-facilitator/build.rs create mode 100644 src/parser/mock-facilitator/src/lib.rs create mode 100644 src/parser/mock-facilitator/src/main.rs diff --git a/Makefile b/Makefile index 172737d9..964a3db3 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,15 @@ out/parser_gateway/index.json: \ $(shell git ls-files images/parser_gateway src) $(call build,parser_gateway) +out/mock_facilitator/index.json: \ + $(shell git ls-files images/mock_facilitator src) + $(call build,mock_facilitator) + .PHONY: non-oci-docker-images non-oci-docker-images: docker buildx build --load --tag anchorageoss-visualsign-parser/parser_app -f images/parser_app/Containerfile . docker buildx build --load --tag anchorageoss-visualsign-parser/parser_gateway -f images/parser_gateway/Containerfile . + docker buildx build --load --tag anchorageoss-visualsign-parser/mock_facilitator -f images/mock_facilitator/Containerfile . define build_context $$( \ diff --git a/docs/superpowers/specs/2026-05-12-x402-gated-http-api-design.md b/docs/superpowers/specs/2026-05-12-x402-gated-http-api-design.md deleted file mode 100644 index f69977da..00000000 --- a/docs/superpowers/specs/2026-05-12-x402-gated-http-api-design.md +++ /dev/null @@ -1,221 +0,0 @@ -# x402-gated HTTP API on parser_gateway - -**Status:** Approved (design) -**Date:** 2026-05-12 -**Owner:** André Stielau - -## Problem - -We want a public HTTP endpoint on the parser that monetizes per-call usage via the x402 v2 payment protocol, while preserving the existing Turnkey deployment path (which must not require payment). The endpoint must work in three deployment shapes: - -- **Local dev** — points at a bundled mock facilitator so the full request/response flow is exercisable without a real wallet or settlement. -- **PayAI** — points at the public PayAI facilitator (`https://facilitator.payai.network`), Solana + EVM, no API key. -- **Custom / other** — facilitator URL, network, asset, recipient, price all overridable via env vars. - -Price-per-call must be configurable, defaulting to *very low* values in `local` and `payai` profiles so dev and pilot deployments don't accidentally over-charge. - -## Goals - -- New `POST /visualsign/api/v2/parse` route with `x402-axum` middleware enforcing payment. -- `GET /health` and `POST /visualsign/api/v1/parse` (existing Turnkey route) remain open and behaviorally unchanged. -- Single source of truth for x402 config via env vars + named profiles. -- Multi-tag config support (`Vec`) even though typical deploys use one tag. -- Bundled `mock_facilitator` binary for local dev and integration tests; **never** built into TDX/Turnkey prod images. -- Fail-fast at startup on any config error; no silently-broken endpoints at request time. - -## Non-goals - -- No changes to `parser_app` (the in-enclave gRPC service). All payment gating sits in the gateway, in front of gRPC. -- No end-to-end test with a real wallet signing real x402 v2 payments. Out of scope; upstream `x402-rs` covers that. -- No support for facilitator-side smart-wallet sig schemes beyond what `x402-axum` provides by default. We layer the middleware as documented; protocol-level additions come for free with upstream versions. -- No Turnkey-signed bypass header for the v2 route. Turnkey continues to use v1. - -## Architecture - -``` - ┌──────────────────────────────────────────┐ - │ parser_gateway (axum 0.8) │ - │ │ - client ─POST──▶ │ GET /health open │ - │ POST /visualsign/api/v1/parse open │ (Turnkey-compatible, unchanged) - │ POST /visualsign/api/v2/parse ┌──x402──┤ - │ │ layer │ - └─────────────────────────────────┼────────┘ - │ gRPC ParserService.Parse │ verify/settle (HTTP) - ▼ ▼ - ┌──────────────┐ ┌──────────────────────────────┐ - │ parser_app │ │ facilitator │ - │ (TDX/vsock) │ │ local : mock_facilitator │ - │ or grpc-srv │ │ payai : facilitator.payai… │ - └──────────────┘ │ custom : X402_FACILITATOR_URL│ - └──────────────────────────────┘ -``` - -Two binaries change; one is new. Everything stays in the existing Cargo workspace. - -- **`parser_gateway`** — axum 0.6 → 0.8 upgrade. New `/visualsign/api/v2/parse` route layered with `x402-axum`'s `X402Middleware`. Existing routes untouched. -- **`mock_facilitator`** — new tiny crate at `src/parser/mock-facilitator/`. Implements the x402 facilitator HTTP surface (`/verify`, `/settle`, `/supported`) and approves everything with a synthetic tx hash. Bundled into the local Containerfile only. -- **`parser_app`** — no changes. - -## Components - -### parser_gateway — extended - -File layout (small refactor; current `main.rs` is 300 LoC and mixes concerns): - -``` -src/parser/gateway/src/ - main.rs # bin entry, env reads, router assembly, shutdown - state.rs # AppState { grpc_client, health_client } - handlers/ - health.rs # GET /health (extracted as-is) - parse.rs # shared parse logic (Turnkey envelope → gRPC → Turnkey envelope) - turnkey.rs # TurnkeyRequestWrapper / TurnkeyResponseWrapper structs - x402_config.rs # X402Profile enum + X402Config loader (env → struct) -``` - -The v1 and v2 routes share the **same** handler body (`handlers::parse::handle`). Both take the Turnkey envelope and emit the Turnkey response shape. The only difference is that v2 is layered with `x402-axum`'s `X402Middleware`. The middleware exposes a chainable `.with_price_tag(...)` builder; for an N-element `price_tags` config, we call it N times during router construction so all tags appear in the 402 `accepts` array. v1 has no x402 layer. - -### x402_config.rs — environment → middleware - -```rust -pub enum X402Profile { Local, PayAi, Custom } - -pub struct X402Config { - pub facilitator_url: Url, - pub facilitator_timeout: Duration, - pub protocol_version: ProtocolVersion, // V2 default - pub price_tags: Vec, // 1..N, non-empty -} - -pub struct PriceTagConfig { - pub network: Network, // base-sepolia, base, solana, ... - pub asset: AssetSpec, // USDC (resolves to per-network address) - pub price_usd: Decimal, - pub pay_to: PayToAddress, // EVM Address or Solana Pubkey - pub scheme: Scheme, // Exact (default) | Upto -} - -impl X402Config { - pub fn from_env() -> Result { /* per matrix below */ } -} -``` - -Profile chooses a seeded `price_tags` of length 1; individual env vars or the JSON override mutate from there. - -### mock_facilitator — new crate - -`src/parser/mock-facilitator/` — axum 0.8 binary, ~100 LoC: - -- `POST /verify` → `{ "isValid": true, "payer": }` -- `POST /settle` → `{ "success": true, "transaction": "0xmock", "network": , "payer": }` -- `GET /supported` → enumerates the local profile's network + asset -- Listens on `MOCK_FACILITATOR_PORT` (default `8090`) -- No real crypto; the gateway's x402-axum middleware does protocol-level header parsing and trusts the facilitator verdict. - -### Container changes - -- **`images/parser_app/Containerfile`** (local dev image): adds `mock_facilitator` to the build stage; entrypoint script starts it on `:8090` alongside existing processes; image defaults `X402_PROFILE=local` and `X402_FACILITATOR_URL=http://127.0.0.1:8090`. -- **TDX / Turnkey prod**: unchanged. `mock_facilitator` is neither built nor shipped. - -## Config matrix - -| Env var | Required? | `local` default | `payai` default | `custom` default | -|---|---|---|---|---| -| `X402_PROFILE` | no — defaults to `local` | `local` | set explicitly to `payai` | set explicitly to `custom` | -| `X402_FACILITATOR_URL` | no in `local`/`payai` | `http://127.0.0.1:8090` | `https://facilitator.payai.network` | **required** | -| `X402_FACILITATOR_TIMEOUT_SECS` | no | `5` | `5` | `5` | -| `X402_PROTOCOL_VERSION` | no | `v2` | `v2` | `v2` | -| `X402_PAYTO` | yes in `payai`/`custom`; optional in `local` | `0x000000000000000000000000000000000000dEaD` | **required** | **required** | -| `X402_PRICE_TAGS_JSON` | no | unset | unset | unset | - -When `X402_PRICE_TAGS_JSON` is unset, the seeded `price_tags` from the profile is used (with `X402_PAYTO` filled in for `payai`/`custom`). When set, it discards the seeded tag and replaces with the parsed list; example: - -``` -X402_PRICE_TAGS_JSON='[ - { "network":"base", "asset":"USDC", "payTo":"0xabc...", "priceUsd":"0.001", "scheme":"exact" }, - { "network":"solana", "asset":"USDC", "payTo":"So1...", "priceUsd":"0.001", "scheme":"exact" } -]' -``` - -Seeded defaults per profile: - -| Profile | seeded `price_tags[0]` | -|---|---| -| `local` | `{ network: base-sepolia, asset: USDC, priceUsd: 0.0001, payTo: $X402_PAYTO or 0x000…dEaD, scheme: exact }` | -| `payai` | `{ network: base, asset: USDC, priceUsd: 0.001, payTo: $X402_PAYTO (required), scheme: exact }` | -| `custom`| no seed — `X402_PRICE_TAGS_JSON` required | - -## Data flow - -``` -1. client ──POST /visualsign/api/v2/parse (no X-PAYMENT)──▶ gateway -2. gateway x402 layer ──▶ 402 Payment Required - body: { x402Version: 2, accepts: [, , ...] } -3. client signs payment authorization -4. client ──POST /visualsign/api/v2/parse + X-PAYMENT: ──▶ gateway -5. gateway x402 layer ──POST /verify──▶ facilitator -6. facilitator ──{ isValid: true, payer }──▶ gateway -7. gateway parse handler ──gRPC ParserService.Parse──▶ parser_app (vsock/local) -8. parser_app ──ParseResponse──▶ gateway -9. gateway x402 layer ──POST /settle──▶ facilitator (post-handler, only on 2xx) -10. facilitator ──{ success: true, transaction }──▶ gateway -11. gateway ──200 + Turnkey envelope + X-PAYMENT-RESPONSE header──▶ client -``` - -Notes: - -- **Settle-after-success:** x402-axum settles only when the wrapped handler returned 2xx. A failed parse (bad chain, malformed payload) is 4xx and we do **not** settle — the payer is not charged for a broken request. Default x402-axum behavior; we do not override it. -- **Body limit:** the existing `DefaultBodyLimit::max(GRPC_MAX_RECV_MSG_SIZE)` layer stays applied router-wide so v2 inherits it. -- **Timeouts:** parse stays at 30s; facilitator verify/settle gets its own 5s timeout (configurable via `X402_FACILITATOR_TIMEOUT_SECS`). - -## Error handling - -| HTTP status | When | Body | -|---|---|---| -| `200` | parse + payment settled | Turnkey envelope | -| `400` | malformed JSON, unknown chain, bad payload | Turnkey envelope with `error` field (same shape as v1) | -| `402` | missing/invalid `X-PAYMENT` header | x402-axum default: `{ x402Version: 2, accepts: [...], error: "..." }` | -| `403` | facilitator says `isValid: false` | x402-axum default: `{ ..., error: "verification failed" }` | -| `500` | gRPC unavailable, missing fields in response | `{ error: "internal error" }` (no gRPC detail leak; matches v1) | -| `502` | facilitator unreachable or transport error | `{ error: "facilitator unavailable" }` | -| `504` | parse > 30s OR facilitator > 5s | `{ error: "request timed out" }` or `{ error: "facilitator timed out" }` | - -Three concrete rules for the gateway code: - -- **Don't settle on handler error.** Rely on x402-axum's default `settle_on_success` behavior. Verify in the integration test. -- **Don't leak gRPC `Status` messages.** The existing v1 handler maps `InvalidArgument → 400`, `NotFound → 404`, everything else to a generic 500 with `eprintln!` of the real error. The shared `handlers::parse::handle` keeps that mapping; v2 inherits it. -- **Fail-fast on startup, not per-request.** Any config error (missing `X402_PAYTO` for `payai`, malformed `X402_PRICE_TAGS_JSON`, invalid network/asset/address, unreachable facilitator on the startup probe) panics the binary at startup with a clear message. The startup probe is a single `GET /supported` request to the configured facilitator URL with the same 5s timeout; if it fails the gateway does not bind its listening port. No silently-broken v2 endpoint. - -## Testing - -**Unit tests** (`gateway/src/x402_config.rs`): -- `from_env` parses each profile correctly with no overrides. -- `X402_PAYTO` override fills in the seeded tag for `payai`/`custom`. -- `X402_PRICE_TAGS_JSON` override replaces the seeded list and rejects malformed input. -- Missing required fields per profile return a clear `ConfigError` (don't panic at this layer; the binary panics, the config does not). - -**Mock facilitator unit tests** (`mock-facilitator/`): -- `/verify`, `/settle`, `/supported` return the expected shapes for a synthetic request. - -**Gateway-level integration test** (extend `src/integration/tests/`): -- Spin up `mock_facilitator` + `parser_grpc_server` + `parser_gateway`. -- **Path 1:** `POST /visualsign/api/v2/parse` with no `X-PAYMENT` → asserts `402`, asserts body contains `accepts: [...]` with the local-profile defaults. -- **Path 2:** same request with a stub `X-PAYMENT` header → asserts `200`, correct Turnkey envelope, `X-PAYMENT-RESPONSE` header present. -- **Path 3:** same request with a malformed payload → asserts `400` AND asserts the mock facilitator never received `/settle`. -- **Path 4:** `POST /visualsign/api/v1/parse` with no `X-PAYMENT` → asserts `200`. Proves v1 stays unprotected. -- **Path 5:** `GET /health` with no `X-PAYMENT` → asserts `200`. Proves health stays unprotected. - -**Manual smoke** (documented, not automated): -- Build the local Containerfile, `docker run`, curl `/health` and `/visualsign/api/v2/parse` from outside the container. - -**Explicitly out of scope for this PR:** -- End-to-end test with a real wallet signing real x402 v2 payments. -- Load test or pricing math test. -- Test that exercises payai's real facilitator (deploy-time smoke, not CI). - -## Risks / open questions - -- **axum 0.6 → 0.8 upgrade**: the existing Turnkey handler uses standard extractors (`Json`, `State`, `DefaultBodyLimit`) and standard handler signatures. Changes are expected to be mechanical, but the upgrade is bundled into this PR and may surface a transitive dep conflict (e.g., `tonic` ↔ `hyper`/`tower` versions). Mitigation: validate `make -C src build` + `make -C src test` early; if blocked, fall back to hand-rolled middleware (one of the rejected options). -- **PayAI facilitator schema drift**: PayAI implements the x402 spec but the schema may drift from upstream `x402-types`. Mitigation: pin `x402-axum` to a known-good version; bump deliberately. -- **Solana payTo address shape**: `PayToAddress` needs to accept both EVM (20 bytes hex) and Solana (32 bytes base58). The `x402-types` crate should already model this; if not, our wrapper enum handles the discrimination at parse time. diff --git a/images/mock_facilitator/Containerfile b/images/mock_facilitator/Containerfile new file mode 100644 index 00000000..94741db1 --- /dev/null +++ b/images/mock_facilitator/Containerfile @@ -0,0 +1,31 @@ +FROM stagex/pallet-rust:1.88.0@sha256:b9021d2b75eac64fe8b931d96dde63ef11792e5023cee77c3471ccc34a95a377 AS build + +# Rust configuration +ENV RUSTFLAGS='-C target-feature=+crt-static' +ENV CARGOFLAGS='--target x86_64-unknown-linux-musl --no-default-features --locked --release' + +# Directory for Rust artifacts +ENV RELEASE_DIR=/src/target/x86_64-unknown-linux-musl/release + +# Version injected at build time via --build-arg (set by make VERSION=...) +ARG VERSION +ENV VERSION=$VERSION + +# Load Rust sources +ADD src /src +WORKDIR /src/ + +# pre-fetch all workspace deps; we need them to build with `--network=none` later +RUN cargo fetch + +WORKDIR /src/parser/mock-facilitator +RUN --network=none <<-EOF + set -eu + cargo build ${CARGOFLAGS} + mkdir -p /rootfs + mv ${RELEASE_DIR}/mock_facilitator /rootfs/ +EOF + +# Use busybox as a base so we can easily cp the pivot binary if needed +FROM stagex/core-busybox:1.36.1@sha256:cac5d773db1c69b832d022c469ccf5f52daf223b91166e6866d42d6983a3b374 AS package +COPY --from=build /rootfs/. . diff --git a/images/parser_gateway/Containerfile b/images/parser_gateway/Containerfile index 4c8816bb..cd108da7 100644 --- a/images/parser_gateway/Containerfile +++ b/images/parser_gateway/Containerfile @@ -28,4 +28,6 @@ EOF # Use busybox as a base so we can easily cp the pivot binary if needed FROM stagex/core-busybox:1.36.1@sha256:cac5d773db1c69b832d022c469ccf5f52daf223b91166e6866d42d6983a3b374 AS package +ENV X402_PROFILE=local +ENV X402_FACILITATOR_URL=http://mock_facilitator:8090 COPY --from=build /rootfs/. . diff --git a/scripts/x402-demo.sh b/scripts/x402-demo.sh new file mode 100755 index 00000000..86be4784 --- /dev/null +++ b/scripts/x402-demo.sh @@ -0,0 +1,343 @@ +#!/usr/bin/env bash +# +# x402-demo.sh — narrated end-to-end walkthrough of the x402 v2 gated HTTP API +# added to parser_gateway. Spins up the mock facilitator, the +# parser gRPC server, and the gateway, then steps through each +# scenario with commentary. +# +# Run from the repo root: ./scripts/x402-demo.sh +# Requirements: bash, curl, jq, base64, cargo. No network needed. +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +# ---------- presentation helpers --------------------------------------------- + +if [ -t 1 ]; then + BOLD=$'\033[1m'; DIM=$'\033[2m'; CYAN=$'\033[36m'; GREEN=$'\033[32m' + YELLOW=$'\033[33m'; RED=$'\033[31m'; MAGENTA=$'\033[35m'; RESET=$'\033[0m' +else + BOLD=''; DIM=''; CYAN=''; GREEN=''; YELLOW=''; RED=''; MAGENTA=''; RESET='' +fi + +chapter() { printf '\n%s%s%s\n%s%s%s\n' "$BOLD$MAGENTA" "════ $1 ════" "$RESET" "$DIM" "$2" "$RESET"; } +say() { printf '%s%s%s\n' "$CYAN" "$1" "$RESET"; } +narrate() { printf '%s│ %s%s\n' "$DIM" "$1" "$RESET"; } +ok() { printf '%s✓ %s%s\n' "$GREEN" "$1" "$RESET"; } +warn() { printf '%s! %s%s\n' "$YELLOW" "$1" "$RESET"; } +fail() { printf '%s✗ %s%s\n' "$RED" "$1" "$RESET"; exit 1; } +cmd() { printf '%s$ %s%s\n' "$YELLOW" "$1" "$RESET"; } + +pause() { sleep "${DEMO_PAUSE:-0.4}"; } + +# ---------- preflight -------------------------------------------------------- + +chapter "Preflight" "Make sure we have everything we need before starting." + +for tool in curl jq base64 cargo; do + if ! command -v "$tool" >/dev/null 2>&1; then + fail "missing tool: $tool" + fi +done +ok "curl, jq, base64, cargo all present" + +MOCK_BIN="src/target/debug/mock_facilitator" +GRPC_BIN="src/target/debug/parser_grpc_server" +GW_BIN="src/target/debug/parser_gateway" + +if [ ! -x "$MOCK_BIN" ] || [ ! -x "$GRPC_BIN" ] || [ ! -x "$GW_BIN" ]; then + warn "one or more binaries missing — running 'make -C src build' (may take a minute)" + make -C src build >/dev/null +fi +ok "binaries built" + +# Free our demo ports if any were left running +for port in 8090 44020 8080; do + pid=$(lsof -ti tcp:"$port" 2>/dev/null || true) + if [ -n "$pid" ]; then + warn "port $port held by pid $pid — killing" + kill "$pid" 2>/dev/null || true + sleep 0.3 + fi +done + +# ---------- process management ----------------------------------------------- + +MOCK_PORT=8090 +GW_PORT=8080 +GRPC_PORT=44020 + +LOG_DIR="$(mktemp -d)" +MOCK_LOG="$LOG_DIR/mock_facilitator.log" +GRPC_LOG="$LOG_DIR/parser_grpc_server.log" +GW_LOG="$LOG_DIR/parser_gateway.log" + +MOCK_PID=""; GRPC_PID=""; GW_PID="" + +cleanup() { + set +e + for pid in "$MOCK_PID" "$GRPC_PID" "$GW_PID"; do + [ -n "$pid" ] && kill "$pid" 2>/dev/null + done + for pid in "$MOCK_PID" "$GRPC_PID" "$GW_PID"; do + [ -n "$pid" ] && wait "$pid" 2>/dev/null + done + if [ -n "${DEMO_KEEP_LOGS:-}" ]; then + say "logs preserved in $LOG_DIR" + else + rm -rf "$LOG_DIR" + fi +} +trap cleanup EXIT INT TERM + +wait_for_url() { + local url="$1"; local label="$2" + for _ in $(seq 1 50); do + if curl -sf "$url" >/dev/null 2>&1; then + ok "$label ready" + return 0 + fi + sleep 0.1 + done + fail "$label never became ready (probed $url)" +} + +wait_port_free() { + local port="$1" + for _ in $(seq 1 30); do + if ! lsof -ti tcp:"$port" >/dev/null 2>&1; then return 0; fi + sleep 0.1 + done +} + +start_stack() { + # Optional first arg: JSON value for X402_PRICE_TAGS_JSON (passed through env, + # NOT word-split — JSON can contain whitespace and newlines). + local price_tags_json="${1:-}" + + wait_port_free "$MOCK_PORT" + wait_port_free "$GRPC_PORT" + wait_port_free "$GW_PORT" + + narrate "starting mock_facilitator on :$MOCK_PORT (approves every payment)" + MOCK_FACILITATOR_PORT=$MOCK_PORT "$MOCK_BIN" >"$MOCK_LOG" 2>&1 & + MOCK_PID=$! + wait_for_url "http://127.0.0.1:$MOCK_PORT/supported" "mock_facilitator" + + narrate "starting parser_grpc_server on :$GRPC_PORT" + EPHEMERAL_FILE="src/integration/fixtures/ephemeral.secret" \ + "$GRPC_BIN" >"$GRPC_LOG" 2>&1 & + GRPC_PID=$! + + if [ -n "$price_tags_json" ]; then + narrate "starting parser_gateway on :$GW_PORT (x402 profile=local + multi-tag JSON)" + GATEWAY_PORT=$GW_PORT \ + GRPC_ADDR="http://127.0.0.1:$GRPC_PORT" \ + X402_PROFILE=local \ + X402_FACILITATOR_URL="http://127.0.0.1:$MOCK_PORT" \ + X402_PRICE_TAGS_JSON="$price_tags_json" \ + "$GW_BIN" >"$GW_LOG" 2>&1 & + else + narrate "starting parser_gateway on :$GW_PORT (x402 profile=local)" + GATEWAY_PORT=$GW_PORT \ + GRPC_ADDR="http://127.0.0.1:$GRPC_PORT" \ + X402_PROFILE=local \ + X402_FACILITATOR_URL="http://127.0.0.1:$MOCK_PORT" \ + "$GW_BIN" >"$GW_LOG" 2>&1 & + fi + GW_PID=$! + wait_for_url "http://127.0.0.1:$GW_PORT/health" "parser_gateway" +} + +stop_stack() { + for pid in "$MOCK_PID" "$GRPC_PID" "$GW_PID"; do + [ -n "$pid" ] && kill "$pid" 2>/dev/null || true + done + for pid in "$MOCK_PID" "$GRPC_PID" "$GW_PID"; do + [ -n "$pid" ] && wait "$pid" 2>/dev/null || true + done + MOCK_PID=""; GRPC_PID=""; GW_PID="" + sleep 0.3 +} + +# ---------- shared fixtures -------------------------------------------------- + +# A real signed legacy Ethereum transfer — same fixture the integration tests use. +ETH_TX_HEX="0xf86c808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83" + +parse_body() { + jq -n --arg tx "$ETH_TX_HEX" \ + '{request: {unsigned_payload: $tx, chain: "CHAIN_ETHEREUM"}}' +} + +# Build a Payment-Signature header from a 402 response's Payment-Required header. +# x402 v2 wire format: base64(JSON({x402Version, accepted: , payload: {...}})) +build_payment_signature() { + local pr_b64="$1" + local requirements + requirements=$(printf %s "$pr_b64" | base64 -d 2>/dev/null | jq '.accepts[0]') + jq -nc --argjson req "$requirements" \ + '{x402Version: 2, accepted: $req, payload: {payer: "0xDEM0DEM0DEM0DEM0DEM0DEM0DEM0DEM0DEM0DEM0"}}' \ + | base64 -w0 +} + +# Pretty-print a JSON snippet, trimmed to N lines. +pp() { jq -C . 2>/dev/null || cat; } + +# ---------- demo ------------------------------------------------------------- + +chapter "Scene 1 — Boot the stack" \ + "Three Rust binaries; the gateway probes the facilitator before binding." + +start_stack +echo +narrate "logs at $LOG_DIR (set DEMO_KEEP_LOGS=1 to keep them)" +pause + +chapter "Scene 2 — /health is open" \ + "Health checks must never require payment; orchestrators can't sign x402." + +cmd "curl -s http://127.0.0.1:$GW_PORT/health" +curl -s "http://127.0.0.1:$GW_PORT/health" | pp +pause + +chapter "Scene 3 — v1 Turnkey endpoint is untouched" \ + "The existing /visualsign/api/v1/parse path stays open — Turnkey deployments keep working." + +cmd "curl -s http://127.0.0.1:$GW_PORT/visualsign/api/v1/parse -d " +parse_body | curl -s -H 'content-type: application/json' \ + -X POST -d @- "http://127.0.0.1:$GW_PORT/visualsign/api/v1/parse" \ + | pp | head -20 +ok "v1 returned a signable payload — no x402 challenge" +pause + +chapter "Scene 4 — v2 endpoint, no payment → 402 Payment Required" \ + "x402-axum intercepts before the handler runs. The Payment-Required header + carries the base64-JSON of accepted payment options." + +cmd "curl -i http://127.0.0.1:$GW_PORT/visualsign/api/v2/parse # no payment header" +hdr_file=$(mktemp) +parse_body | curl -s -H 'content-type: application/json' \ + -X POST -d @- -D "$hdr_file" -o /dev/null \ + "http://127.0.0.1:$GW_PORT/visualsign/api/v2/parse" + +status=$(awk 'NR==1 {print $2}' "$hdr_file") +pr_b64=$(awk -F': ' 'tolower($1)=="payment-required" {sub(/\r$/, "", $2); print $2}' "$hdr_file" | head -1) + +narrate "status: $status" +if [ -z "$pr_b64" ]; then + warn "no Payment-Required header — printing raw headers for debugging:" + cat "$hdr_file" +else + ok "Payment-Required header found (${#pr_b64} bytes base64)" + say "decoded payment requirements:" + printf %s "$pr_b64" | base64 -d | pp +fi +rm -f "$hdr_file" +pause + +chapter "Scene 5 — v2 with payment → 200 + signable payload + settle" \ + "We echo the requirements back as a (mock) signed payment. + x402-axum verifies via mock_facilitator, calls the handler, then settles." + +sig=$(build_payment_signature "$pr_b64") +narrate "Payment-Signature header: ${sig:0:48}… (truncated, ${#sig} bytes)" + +cmd "curl -i -H 'Payment-Signature: ' http://127.0.0.1:$GW_PORT/visualsign/api/v2/parse" +resp_hdr=$(mktemp) +resp_body=$(parse_body | curl -s -H 'content-type: application/json' \ + -H "Payment-Signature: $sig" \ + -X POST -d @- -D "$resp_hdr" \ + "http://127.0.0.1:$GW_PORT/visualsign/api/v2/parse") + +status=$(awk 'NR==1 {print $2}' "$resp_hdr") +narrate "status: $status" + +payment_resp=$(awk -F': ' 'tolower($1)=="payment-response" {sub(/\r$/, "", $2); print $2}' "$resp_hdr" | head -1) +if [ -n "$payment_resp" ]; then + ok "Payment-Response header present (${#payment_resp} bytes base64)" + say "decoded settlement receipt:" + printf %s "$payment_resp" | base64 -d | pp +else + warn "no Payment-Response header (x402-axum may emit a differently-named header in this version)" +fi +echo +say "and the actual response body:" +printf %s "$resp_body" | pp | head -20 +rm -f "$resp_hdr" +pause + +chapter "Scene 6 — v2 with malformed tx → 400, no settlement" \ + "The middleware's settle_on_success contract: a 4xx handler response + means the payment is verified but never actually settled. + (The mock approves anything, so we can't directly observe non-settlement here, + but the contract is documented in x402-axum and exercised by Task 10's path 3.)" + +cmd "curl -i -H 'Payment-Signature: ' -d '{\"request\":{\"unsigned_payload\":\"0xnope\",...}}'" +bad_body='{"request": {"unsigned_payload": "0xnope", "chain": "CHAIN_ETHEREUM"}}' +status=$(printf %s "$bad_body" | curl -s -o /dev/null -w '%{http_code}' \ + -H 'content-type: application/json' \ + -H "Payment-Signature: $sig" \ + -X POST -d @- "http://127.0.0.1:$GW_PORT/visualsign/api/v2/parse") +narrate "status: $status" +if [ "$status" = "400" ]; then + ok "parser rejected the payload before settle" +else + warn "expected 400, got $status" +fi +pause + +chapter "Scene 7 — Multi-tag config via X402_PRICE_TAGS_JSON" \ + "Restart the gateway advertising TWO payment options (base USDC OR solana USDC)." + +stop_stack + +multi=$(jq -nc '[ + { network: "base", asset: "USDC", priceUsd: "0.002", + payTo: { evm: "0xfedcba0000000000000000000000000000000099" }, + scheme: "exact" }, + { network: "solana", asset: "USDC", priceUsd: "0.002", + payTo: { solana: "EGBQqKn968sVv5cQh5Cr72pSTHfxsuzq7o7asqYB5uEV" }, + scheme: "exact" } +]') + +start_stack "$multi" + +cmd "curl -i http://127.0.0.1:$GW_PORT/visualsign/api/v2/parse # no payment" +hdr_file=$(mktemp) +parse_body | curl -s -H 'content-type: application/json' \ + -X POST -d @- -D "$hdr_file" -o /dev/null \ + "http://127.0.0.1:$GW_PORT/visualsign/api/v2/parse" +pr_b64=$(awk -F': ' 'tolower($1)=="payment-required" {sub(/\r$/, "", $2); print $2}' "$hdr_file" | head -1) +if [ -z "$pr_b64" ]; then + warn "no Payment-Required header — gateway may not be up; raw headers:" + cat "$hdr_file" +else + say "advertised accepts (summary):" + printf %s "$pr_b64" | base64 -d \ + | jq -C '.accepts | map({network, scheme, amount, payTo})' + ok "two payment options advertised on a single endpoint" +fi +rm -f "$hdr_file" + +# ---------- close out -------------------------------------------------------- + +chapter "Curtain" \ + "Stack will shut down cleanly when the script exits." + +cat < String { + // Binaries are built by `make -C src build` before running these tests. + // The integration crate lives at src/integration/, so binaries are at ../target/debug/. + format!("../target/debug/{name}") +} + +// ── Process lifecycle ───────────────────────────────────────────────────────── + +struct Procs { + mock: Child, + grpc: Child, + gateway: Child, +} + +impl Drop for Procs { + fn drop(&mut self) { + // Kill children and reap them so the OS releases their ports promptly. + let _ = self.mock.kill(); + let _ = self.grpc.kill(); + let _ = self.gateway.kill(); + let _ = self.mock.wait(); + let _ = self.grpc.wait(); + let _ = self.gateway.wait(); + } +} + +/// Wait until a TCP port is no longer bound (i.e., available for reuse). +async fn wait_until_port_free(port: u16) { + for _ in 0..100 { + if TcpListener::bind(("127.0.0.1", port)).is_ok() { + return; + } + sleep(Duration::from_millis(50)).await; + } + // If still bound after 5 s, proceed anyway — the next bind will fail and + // give a useful error. +} + +/// Wait until an HTTP endpoint returns 200. +async fn wait_ready(url: &str) { + let client = reqwest::Client::new(); + for _ in 0..100 { + if let Ok(r) = client.get(url).send().await { + if r.status().is_success() { + return; + } + } + sleep(Duration::from_millis(100)).await; + } + panic!("service at {url} never became ready (timed out after 10 s)"); +} + +async fn start_procs() -> Procs { + // --- Friction 2: startup ordering --- + // parser_gateway probes mock_facilitator at startup. We must ensure + // mock_facilitator is ready before spawning the gateway. + + // Wait until the ports are free (important between sequential test runs). + wait_until_port_free(MOCK_PORT).await; + wait_until_port_free(GW_PORT).await; + + // 1. Start mock_facilitator first. + let mock = Command::new(target_bin("mock_facilitator")) + .env("MOCK_FACILITATOR_PORT", MOCK_PORT.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn mock_facilitator"); + + // Wait for mock to be ready before proceeding. + wait_ready(&format!("http://127.0.0.1:{MOCK_PORT}/supported")).await; + + // 2. Start parser_grpc_server. + // Friction 4: no CLI args; binds 0.0.0.0:44020 by default. + // We override the default port via an env var trick: the binary only reads + // EPHEMERAL_FILE. To run on a different port we would need to patch the + // binary — instead we use the hardcoded default (44020) and point the + // gateway at it. The GRPC_PORT constant is used only for documentation; + // the actual grpc server always binds 44020. + // + // Because port 44020 is fixed, we wait for it to free up as well. + wait_until_port_free(44020).await; + + let grpc = Command::new(target_bin("parser_grpc_server")) + // The server defaults to "integration/fixtures/ephemeral.secret" relative + // to cwd. When cargo runs integration tests the cwd is src/integration/. + .env("EPHEMERAL_FILE", "fixtures/ephemeral.secret") + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn parser_grpc_server"); + + // 3. Start the gateway last — it probes the mock at startup. + // Friction 5: env var names confirmed from gateway/src/main.rs. + let gateway = Command::new(target_bin("parser_gateway")) + .env("GATEWAY_PORT", GW_PORT.to_string()) + // grpc server always listens on 44020 (hardcoded in binary) + .env("GRPC_ADDR", "http://127.0.0.1:44020") + .env("X402_PROFILE", "local") + .env( + "X402_FACILITATOR_URL", + format!("http://127.0.0.1:{MOCK_PORT}"), + ) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn parser_gateway"); + + // Wait for gateway health to confirm all three services are up. + wait_ready(&format!("http://127.0.0.1:{GW_PORT}/health")).await; + + Procs { + mock, + grpc, + gateway, + } +} + +// ── Payment header helpers ──────────────────────────────────────────────────── + +/// Fetch the 402 `Payment-Required` header, decode it, and extract the first +/// entry from `accepts` as a raw JSON Value. +/// +/// This gives us the exact `PaymentRequirements` the server is offering, which +/// we need to embed in the `accepted` field of the V2 `Payment-Signature` payload. +async fn fetch_v2_requirements() -> serde_json::Value { + use base64::Engine; + + let body = serde_json::json!({ + "request": { "unsigned_payload": "0xdeadbeef", "chain": "CHAIN_ETHEREUM" } + }); + + let resp = reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{GW_PORT}/visualsign/api/v2/parse" + )) + .json(&body) + .send() + .await + .expect("send probe request"); + + assert_eq!(resp.status(), 402, "expected 402 for probe"); + + let header = resp + .headers() + .get("Payment-Required") + .expect("Payment-Required header must be present on 402") + .to_str() + .expect("header must be valid UTF-8") + .to_string(); + + let decoded = base64::engine::general_purpose::STANDARD + .decode(header.as_bytes()) + .expect("Payment-Required must be base64"); + + let payment_required: serde_json::Value = + serde_json::from_slice(&decoded).expect("Payment-Required must be JSON"); + + let accepts = payment_required["accepts"] + .as_array() + .expect("accepts must be array"); + + assert!(!accepts.is_empty(), "accepts must not be empty"); + + accepts[0].clone() +} + +/// Build a well-formed V2 `Payment-Signature` header value. +/// +/// V2 `PaymentPayload` wire shape (camelCase, per x402-types v2.rs): +/// +/// ```json +/// { +/// "accepted": { }, +/// "payload": { /* scheme-specific; mock_facilitator ignores contents */ }, +/// "x402Version": 2 +/// } +/// ``` +/// +/// The header value is the base64 (standard) encoding of the JSON bytes. +fn build_payment_signature(requirements: &serde_json::Value) -> String { + use base64::Engine; + + let payload = serde_json::json!({ + "x402Version": 2, + "accepted": requirements, + "payload": { + "payer": "0x000000000000000000000000000000000000AAAA", + "signature": "0xdeadbeef" + } + }); + + base64::engine::general_purpose::STANDARD.encode(payload.to_string()) +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +/// A valid signed Ethereum legacy transaction (EIP-155, chain_id=1). +/// Same fixture used in parser.rs integration tests. +const ETH_TX_HEX: &str = "0xf86c808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// Path 1: POST /visualsign/api/v2/parse without any payment header → 402. +/// V2 returns an empty body and puts the payment requirements in the +/// `Payment-Required` header (base64 JSON), not in the response body. +#[tokio::test] +async fn path1_v2_without_payment_returns_402() { + let _p = start_procs().await; + + let body = serde_json::json!({ + "request": { "unsigned_payload": "0xdeadbeef", "chain": "CHAIN_ETHEREUM" } + }); + + let resp = reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{GW_PORT}/visualsign/api/v2/parse" + )) + .json(&body) + .send() + .await + .unwrap(); + + // Path 1 asserts on the 402 regardless of the chain name — the middleware + // gates on payment before the handler ever sees the chain name. So we can + // use any valid-looking body here; the chain value is irrelevant for the + // 402 assertion itself. + assert_eq!(resp.status(), 402, "expected 402 Payment Required"); + + // The V2 protocol returns payment info in the `Payment-Required` header. + let payment_required_header = resp + .headers() + .get("Payment-Required") + .expect("Payment-Required header must be present"); + + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(payment_required_header.as_bytes()) + .expect("Payment-Required must be base64"); + + let v: serde_json::Value = + serde_json::from_slice(&decoded).expect("Payment-Required must be JSON"); + + let accepts = v["accepts"].as_array().expect("accepts must be array"); + assert!(!accepts.is_empty(), "accepts must not be empty"); + + // Local profile uses base-sepolia, which maps to CAIP-2 "eip155:84532". + let has_base_sepolia = accepts.iter().any(|t| { + t["network"] + .as_str() + .map(|n| n.contains("84532") || n.contains("base-sepolia")) + .unwrap_or(false) + }); + assert!( + has_base_sepolia, + "accepts must include base-sepolia; got: {accepts:?}" + ); +} + +/// Path 2: POST /visualsign/api/v2/parse with a valid V2 payment → 200 with parse result. +/// We first probe the 402 to learn the exact requirements, then echo them back in `accepted`. +#[tokio::test] +async fn path2_v2_with_valid_payment_returns_200() { + let _p = start_procs().await; + + // Fetch actual requirements from the 402 response. + let requirements = fetch_v2_requirements().await; + let payment_header = build_payment_signature(&requirements); + + let body = serde_json::json!({ + "request": { "unsigned_payload": ETH_TX_HEX, "chain": "CHAIN_ETHEREUM" } + }); + + let resp = reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{GW_PORT}/visualsign/api/v2/parse" + )) + .header("Payment-Signature", payment_header) + .json(&body) + .send() + .await + .unwrap(); + + let status = resp.status(); + let body_text = resp.text().await.unwrap(); + assert_eq!(status, 200, "expected 200; body: {body_text}"); + + let v: serde_json::Value = serde_json::from_str(&body_text).expect("must be JSON"); + assert!( + v["response"]["parsedTransaction"]["payload"]["signablePayload"].is_string(), + "response must contain signablePayload; got: {v}" + ); +} + +/// Path 3: POST /visualsign/api/v2/parse with a valid payment but an invalid transaction +/// payload → 400. The gRPC parser rejects it before settlement. +#[tokio::test] +async fn path3_v2_valid_payment_bad_tx_returns_400() { + let _p = start_procs().await; + + let requirements = fetch_v2_requirements().await; + let payment_header = build_payment_signature(&requirements); + + let body = serde_json::json!({ + "request": { "unsigned_payload": "not-hex-not-base64-not-valid", "chain": "CHAIN_ETHEREUM" } + }); + + let resp = reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{GW_PORT}/visualsign/api/v2/parse" + )) + .header("Payment-Signature", payment_header) + .json(&body) + .send() + .await + .unwrap(); + + assert_eq!( + resp.status(), + 400, + "expected 400 Bad Request for invalid tx" + ); +} + +/// Path 4: POST /visualsign/api/v1/parse without payment header → 200 (open route). +#[tokio::test] +async fn path4_v1_without_payment_returns_200() { + let _p = start_procs().await; + + let body = serde_json::json!({ + "request": { "unsigned_payload": ETH_TX_HEX, "chain": "CHAIN_ETHEREUM" } + }); + + let resp = reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{GW_PORT}/visualsign/api/v1/parse" + )) + .json(&body) + .send() + .await + .unwrap(); + + assert_ne!(resp.status(), 402, "v1 route must not require payment"); + assert_eq!(resp.status(), 200, "v1 route must return 200"); +} + +/// Path 5: GET /health → 200 with no authentication. +#[tokio::test] +async fn path5_health_open() { + let _p = start_procs().await; + + let resp = reqwest::get(format!("http://127.0.0.1:{GW_PORT}/health")) + .await + .unwrap(); + + assert_eq!(resp.status(), 200); +} diff --git a/src/parser/gateway/Cargo.toml b/src/parser/gateway/Cargo.toml index 0430161f..08e4036c 100644 --- a/src/parser/gateway/Cargo.toml +++ b/src/parser/gateway/Cargo.toml @@ -5,13 +5,45 @@ edition.workspace = true publish = false [dependencies] +# internal paths generated = { path = "../../generated", features = ["tonic_types", "serde_derive"] } health_check = { path = "../../health_check" } host_primitives = { path = "../../host_primitives" } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "time"] } -serde_json = { workspace = true } -axum = { version = "0.6.20", features = ["http1", "tokio", "json"], default-features = false } + +# core runtime + serialization +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "time", "net"] } serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } + +# axum +axum = { version = "0.8", features = ["http1", "tokio", "json"], default-features = false } + +# x402 +x402-axum = "1.4" +x402-types = "1.4" +x402-chain-eip155 = { version = "1", features = ["server"] } +x402-chain-solana = { version = "1", features = ["server"] } + +# supporting primitives +alloy-primitives = "1" +solana-pubkey = "2" +rust_decimal = "1" +url = "2" + +# error handling +thiserror = "1" + +# http client (for startup facilitator probe, Task 9) +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } + +[lib] +name = "parser_gateway" +path = "src/lib.rs" + +[[bin]] +name = "parser_gateway" +path = "src/main.rs" [lints] workspace = true diff --git a/src/parser/gateway/src/handlers/health.rs b/src/parser/gateway/src/handlers/health.rs new file mode 100644 index 00000000..59025cee --- /dev/null +++ b/src/parser/gateway/src/handlers/health.rs @@ -0,0 +1,52 @@ +//! Health-check handler — proxies to the gRPC backend's health service. + +use crate::state::AppState; +use axum::{Json, extract::State, http::StatusCode}; +use generated::grpc::health::v1::{HealthCheckRequest, health_check_response::ServingStatus}; +use generated::tonic; +use std::time::Duration; + +const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(2); + +pub async fn health_handler( + State(AppState { + mut health_client, .. + }): State, +) -> (StatusCode, Json) { + let request = tonic::Request::new(HealthCheckRequest { + service: health_check::DEFAULT_SERVICE.to_string(), + }); + match tokio::time::timeout(HEALTH_CHECK_TIMEOUT, health_client.check(request)).await { + Ok(Ok(resp)) => { + let status = resp.into_inner().status; + if status == ServingStatus::Serving as i32 { + (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))) + } else { + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "status": "unhealthy", + "reason": "grpc service not serving" + })), + ) + } + } + Ok(Err(e)) => { + eprintln!("health check failed: {e}"); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({"status": "unhealthy", "reason": "backend unavailable"})), + ) + } + Err(_) => { + eprintln!("health check timed out after {HEALTH_CHECK_TIMEOUT:?}"); + ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "status": "unhealthy", + "reason": "health check timed out" + })), + ) + } + } +} diff --git a/src/parser/gateway/src/handlers/mod.rs b/src/parser/gateway/src/handlers/mod.rs new file mode 100644 index 00000000..011597e8 --- /dev/null +++ b/src/parser/gateway/src/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod health; +pub mod parse; diff --git a/src/parser/gateway/src/handlers/parse.rs b/src/parser/gateway/src/handlers/parse.rs new file mode 100644 index 00000000..24c0428d --- /dev/null +++ b/src/parser/gateway/src/handlers/parse.rs @@ -0,0 +1,120 @@ +//! Shared parse handler. Used by both /visualsign/api/v1/parse (open, Turnkey) +//! and /visualsign/api/v2/parse (x402-gated). + +use crate::state::AppState; +use crate::turnkey::{ + TurnkeyParsedTransaction, TurnkeyPayload, TurnkeyRequestWrapper, TurnkeyResponse, + TurnkeyResponseWrapper, TurnkeySignature, error_response, +}; +use axum::{Json, extract::State, http::StatusCode}; +use generated::parser::{Chain, ChainMetadata, ParseRequest, SignatureScheme}; +use generated::tonic; +use std::time::Duration; + +const PARSE_TIMEOUT: Duration = Duration::from_secs(30); + +pub async fn parse_handler( + State(AppState { + mut grpc_client, .. + }): State, + Json(wrapper): Json, +) -> (StatusCode, Json) { + let chain = match Chain::from_str_name(&wrapper.request.chain) { + Some(c) => c as i32, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(error_response(format!( + "unknown chain: {}", + wrapper.request.chain + ))), + ); + } + }; + + let request = tonic::Request::new(ParseRequest { + unsigned_payload: wrapper.request.unsigned_payload, + chain, + chain_metadata: wrapper.request.chain_metadata.map(ChainMetadata::from), + }); + + let response = match tokio::time::timeout(PARSE_TIMEOUT, grpc_client.parse(request)).await { + Ok(Ok(r)) => r.into_inner(), + Ok(Err(e)) => { + let (http_status, msg) = match e.code() { + tonic::Code::InvalidArgument => (StatusCode::BAD_REQUEST, e.message().to_string()), + tonic::Code::NotFound => (StatusCode::NOT_FOUND, e.message().to_string()), + _ => { + eprintln!("gRPC error: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal error".to_string(), + ) + } + }; + return (http_status, Json(error_response(msg))); + } + Err(_) => { + eprintln!("parse RPC timed out after {PARSE_TIMEOUT:?}"); + return ( + StatusCode::GATEWAY_TIMEOUT, + Json(error_response("request timed out".to_string())), + ); + } + }; + + let parsed_tx = match response.parsed_transaction { + Some(tx) => tx, + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(error_response( + "missing parsed_transaction in response".to_string(), + )), + ); + } + }; + + let payload = match parsed_tx.payload { + Some(p) => p, + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(error_response("missing payload in response".to_string())), + ); + } + }; + + let signature = parsed_tx.signature.map(|sig| { + let scheme = match sig.scheme { + x if x == SignatureScheme::TurnkeyP256EphemeralKey as i32 => { + SignatureScheme::TurnkeyP256EphemeralKey + } + _ => SignatureScheme::Unspecified, + }; + let scheme_str = scheme.as_str_name(); + TurnkeySignature { + message: sig.message, + public_key: sig.public_key, + scheme: scheme_str.to_string(), + signature: sig.signature, + } + }); + + ( + StatusCode::OK, + Json(TurnkeyResponseWrapper { + response: TurnkeyResponse { + parsed_transaction: TurnkeyParsedTransaction { + payload: TurnkeyPayload { + signable_payload: payload.parsed_payload, + metadata_digest: payload.metadata_digest, + input_payload_digest: payload.input_payload_digest, + }, + signature, + }, + }, + error: None, + }), + ) +} diff --git a/src/parser/gateway/src/lib.rs b/src/parser/gateway/src/lib.rs new file mode 100644 index 00000000..4fd40213 --- /dev/null +++ b/src/parser/gateway/src/lib.rs @@ -0,0 +1,11 @@ +//! Parser HTTP gateway — library entrypoint so integration tests can +//! construct the same router the binary serves. +// TODO(#231): Remove these exemptions and fix violations in a follow-up PR. +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] +#![allow(clippy::panic)] + +pub mod handlers; +pub mod state; +pub mod turnkey; +pub mod x402_config; diff --git a/src/parser/gateway/src/main.rs b/src/parser/gateway/src/main.rs index daf23f9f..0e3c53bd 100644 --- a/src/parser/gateway/src/main.rs +++ b/src/parser/gateway/src/main.rs @@ -4,277 +4,15 @@ #![allow(clippy::panic)] use axum::{ - Json, Router, + Router, extract::DefaultBodyLimit, - extract::State, - http::StatusCode, routing::{get, post}, }; -use generated::grpc::health::v1::{ - HealthCheckRequest, health_check_response::ServingStatus, health_client::HealthClient, -}; -use generated::parser::{ - Chain, ChainMetadata, EthereumMetadata, ParseRequest, SignatureScheme, SolanaMetadata, - chain_metadata, parser_service_client::ParserServiceClient, -}; +use generated::grpc::health::v1::health_client::HealthClient; +use generated::parser::parser_service_client::ParserServiceClient; use generated::tonic; use host_primitives::GRPC_MAX_RECV_MSG_SIZE; -use serde::{Deserialize, Serialize}; use std::net::SocketAddr; -use std::time::Duration; - -#[derive(Deserialize)] -struct TurnkeyRequestWrapper { - request: TurnkeyRequest, -} - -/// Tagged representation of chain metadata for unambiguous JSON deserialization. -/// -/// The generated `ChainMetadata` uses `serde(untagged)` on the inner oneof enum, which means -/// serde tries Ethereum first. A Solana payload with only `networkId` would be silently -/// decoded as `EthereumMetadata`. This wrapper uses an explicit `chain` discriminator. -#[derive(Deserialize)] -#[serde(tag = "chain", rename_all = "camelCase")] -enum ChainMetadataInput { - #[serde(rename = "CHAIN_ETHEREUM")] - Ethereum(EthereumMetadata), - #[serde(rename = "CHAIN_SOLANA")] - Solana(SolanaMetadata), -} - -impl From for ChainMetadata { - fn from(input: ChainMetadataInput) -> Self { - let metadata = match input { - ChainMetadataInput::Ethereum(eth) => chain_metadata::Metadata::Ethereum(eth), - ChainMetadataInput::Solana(sol) => chain_metadata::Metadata::Solana(sol), - }; - ChainMetadata { - metadata: Some(metadata), - } - } -} - -#[derive(Deserialize)] -struct TurnkeyRequest { - unsigned_payload: String, - chain: String, - chain_metadata: Option, -} - -#[derive(Serialize)] -struct TurnkeyResponseWrapper { - response: TurnkeyResponse, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct TurnkeyResponse { - parsed_transaction: TurnkeyParsedTransaction, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct TurnkeyParsedTransaction { - payload: TurnkeyPayload, - #[serde(skip_serializing_if = "Option::is_none")] - signature: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct TurnkeyPayload { - signable_payload: String, - metadata_digest: String, - input_payload_digest: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct TurnkeySignature { - message: String, - public_key: String, - scheme: String, - signature: String, -} - -type GrpcClient = ParserServiceClient; - -#[derive(Clone)] -struct AppState { - grpc_client: GrpcClient, - health_client: HealthClient, -} - -const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(2); -const PARSE_TIMEOUT: Duration = Duration::from_secs(30); - -async fn health_handler( - State(AppState { - mut health_client, .. - }): State, -) -> (StatusCode, Json) { - let request = tonic::Request::new(HealthCheckRequest { - service: health_check::DEFAULT_SERVICE.to_string(), - }); - match tokio::time::timeout(HEALTH_CHECK_TIMEOUT, health_client.check(request)).await { - Ok(Ok(resp)) => { - let status = resp.into_inner().status; - if status == ServingStatus::Serving as i32 { - (StatusCode::OK, Json(serde_json::json!({"status": "ok"}))) - } else { - ( - StatusCode::SERVICE_UNAVAILABLE, - Json( - serde_json::json!({"status": "unhealthy", "reason": "grpc service not serving"}), - ), - ) - } - } - Ok(Err(e)) => { - eprintln!("health check failed: {e}"); - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(serde_json::json!({"status": "unhealthy", "reason": "backend unavailable"})), - ) - } - Err(_) => { - eprintln!("health check timed out after {HEALTH_CHECK_TIMEOUT:?}"); - ( - StatusCode::SERVICE_UNAVAILABLE, - Json( - serde_json::json!({"status": "unhealthy", "reason": "health check timed out"}), - ), - ) - } - } -} - -async fn parse_handler( - State(AppState { - mut grpc_client, .. - }): State, - Json(wrapper): Json, -) -> (StatusCode, Json) { - let chain = match Chain::from_str_name(&wrapper.request.chain) { - Some(c) => c as i32, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(error_response(format!( - "unknown chain: {}", - wrapper.request.chain - ))), - ); - } - }; - - let request = tonic::Request::new(ParseRequest { - unsigned_payload: wrapper.request.unsigned_payload, - chain, - chain_metadata: wrapper.request.chain_metadata.map(ChainMetadata::from), - }); - - let response = match tokio::time::timeout(PARSE_TIMEOUT, grpc_client.parse(request)).await { - Ok(Ok(r)) => r.into_inner(), - Ok(Err(e)) => { - let (http_status, msg) = match e.code() { - tonic::Code::InvalidArgument => (StatusCode::BAD_REQUEST, e.message().to_string()), - tonic::Code::NotFound => (StatusCode::NOT_FOUND, e.message().to_string()), - _ => { - eprintln!("gRPC error: {e}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "internal error".to_string(), - ) - } - }; - return (http_status, Json(error_response(msg))); - } - Err(_) => { - eprintln!("parse RPC timed out after {PARSE_TIMEOUT:?}"); - return ( - StatusCode::GATEWAY_TIMEOUT, - Json(error_response("request timed out".to_string())), - ); - } - }; - - let parsed_tx = match response.parsed_transaction { - Some(tx) => tx, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(error_response( - "missing parsed_transaction in response".to_string(), - )), - ); - } - }; - - let payload = match parsed_tx.payload { - Some(p) => p, - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(error_response("missing payload in response".to_string())), - ); - } - }; - - let signature = parsed_tx.signature.map(|sig| { - let scheme = match sig.scheme { - x if x == SignatureScheme::TurnkeyP256EphemeralKey as i32 => { - SignatureScheme::TurnkeyP256EphemeralKey - } - _ => SignatureScheme::Unspecified, - }; - let scheme_str = scheme.as_str_name(); - TurnkeySignature { - message: sig.message, - public_key: sig.public_key, - scheme: scheme_str.to_string(), - signature: sig.signature, - } - }); - - ( - StatusCode::OK, - Json(TurnkeyResponseWrapper { - response: TurnkeyResponse { - parsed_transaction: TurnkeyParsedTransaction { - payload: TurnkeyPayload { - signable_payload: payload.parsed_payload, - metadata_digest: payload.metadata_digest, - input_payload_digest: payload.input_payload_digest, - }, - signature, - }, - }, - error: None, - }), - ) -} - -// SHA-256 of empty input: used as the canonical "no data" sentinel for digest fields. -const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - -fn error_response(msg: String) -> TurnkeyResponseWrapper { - TurnkeyResponseWrapper { - response: TurnkeyResponse { - parsed_transaction: TurnkeyParsedTransaction { - payload: TurnkeyPayload { - signable_payload: String::new(), - metadata_digest: EMPTY_SHA256.to_string(), - input_payload_digest: EMPTY_SHA256.to_string(), - }, - signature: None, - }, - }, - error: Some(msg), - } -} #[tokio::main] async fn main() -> Result<(), Box> { @@ -297,27 +35,68 @@ async fn main() -> Result<(), Box> { .max_encoding_message_size(GRPC_MAX_RECV_MSG_SIZE); let health_client = HealthClient::new(channel); - let state = AppState { + let state = parser_gateway::state::AppState { grpc_client, health_client, }; + let x402_cfg = + parser_gateway::x402_config::X402Config::from_env().expect("invalid X402 configuration"); + let x402_middleware = x402_cfg + .build_middleware() + .expect("invalid X402 price tags"); + + if let Err(e) = probe_facilitator(&x402_cfg.facilitator_url, x402_cfg.facilitator_timeout).await + { + return Err(format!( + "x402 facilitator probe failed for {}: {e}", + x402_cfg.facilitator_url + ) + .into()); + } + println!("x402 facilitator probe OK"); + let app = Router::new() - .route("/health", get(health_handler)) - .route("/visualsign/api/v1/parse", post(parse_handler)) + .route( + "/health", + get(parser_gateway::handlers::health::health_handler), + ) + .route( + "/visualsign/api/v1/parse", + post(parser_gateway::handlers::parse::parse_handler), + ) + .route( + "/visualsign/api/v2/parse", + post(parser_gateway::handlers::parse::parse_handler).layer(x402_middleware), + ) .layer(DefaultBodyLimit::max(GRPC_MAX_RECV_MSG_SIZE)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); println!("parser_gateway {} listening on {addr}", env!("VERSION")); - axum::Server::bind(&addr) - .serve(app.into_make_service()) + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; Ok(()) } +async fn probe_facilitator( + url: &url::Url, + timeout: std::time::Duration, +) -> Result<(), Box> { + let mut probe_url = url.clone(); + let base_path = probe_url.path().trim_end_matches('/').to_string(); + probe_url.set_path(&format!("{base_path}/supported")); + let client = reqwest::Client::builder().timeout(timeout).build()?; + let resp = client.get(probe_url).send().await?; + if !resp.status().is_success() { + return Err(format!("facilitator returned {}", resp.status()).into()); + } + Ok(()) +} + async fn shutdown_signal() { let ctrl_c = tokio::signal::ctrl_c(); #[cfg(unix)] @@ -334,47 +113,3 @@ async fn shutdown_signal() { println!("Shutting down gateway"); } - -#[cfg(test)] -mod tests { - use super::*; - use generated::parser::{EthereumMetadata, SolanaMetadata}; - - #[test] - fn error_response_has_empty_sha256_digests() { - let resp = error_response("something broke".to_string()); - let payload = &resp.response.parsed_transaction.payload; - assert_eq!(payload.metadata_digest, EMPTY_SHA256); - assert_eq!(payload.input_payload_digest, EMPTY_SHA256); - assert!(payload.signable_payload.is_empty()); - assert_eq!(resp.error.as_deref(), Some("something broke")); - } - - #[test] - fn chain_metadata_input_solana_not_misread_as_ethereum() { - let json = r#"{"chain":"CHAIN_SOLANA","networkId":"solana-mainnet"}"#; - let parsed: ChainMetadataInput = serde_json::from_str(json).unwrap(); - assert!(matches!(parsed, ChainMetadataInput::Solana(_))); - } - - #[test] - fn chain_metadata_input_ethereum_deserializes() { - let json = r#"{"chain":"CHAIN_ETHEREUM","networkId":"ETHEREUM_MAINNET"}"#; - let parsed: ChainMetadataInput = serde_json::from_str(json).unwrap(); - assert!(matches!(parsed, ChainMetadataInput::Ethereum(_))); - } - - #[test] - fn ethereum_metadata_abi_mappings_defaults_when_omitted() { - let json = r#"{"networkId":"ETHEREUM_MAINNET"}"#; - let parsed: EthereumMetadata = serde_json::from_str(json).unwrap(); - assert!(parsed.abi_mappings.is_empty()); - } - - #[test] - fn solana_metadata_idl_mappings_defaults_when_omitted() { - let json = r#"{"networkId":"SOLANA_MAINNET"}"#; - let parsed: SolanaMetadata = serde_json::from_str(json).unwrap(); - assert!(parsed.idl_mappings.is_empty()); - } -} diff --git a/src/parser/gateway/src/state.rs b/src/parser/gateway/src/state.rs new file mode 100644 index 00000000..cc43e60a --- /dev/null +++ b/src/parser/gateway/src/state.rs @@ -0,0 +1,13 @@ +//! Shared application state for the gateway router. + +use generated::grpc::health::v1::health_client::HealthClient; +use generated::parser::parser_service_client::ParserServiceClient; +use generated::tonic; + +pub type GrpcClient = ParserServiceClient; + +#[derive(Clone)] +pub struct AppState { + pub grpc_client: GrpcClient, + pub health_client: HealthClient, +} diff --git a/src/parser/gateway/src/turnkey.rs b/src/parser/gateway/src/turnkey.rs new file mode 100644 index 00000000..0d1584be --- /dev/null +++ b/src/parser/gateway/src/turnkey.rs @@ -0,0 +1,145 @@ +//! Turnkey-compatible request/response envelope for parse endpoints. + +use generated::parser::{ChainMetadata, EthereumMetadata, SolanaMetadata, chain_metadata}; +use serde::{Deserialize, Serialize}; + +/// SHA-256 of empty input: used as the canonical "no data" sentinel for digest fields +/// in error responses, where we have no real payload to digest. +pub const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + +#[derive(Deserialize)] +pub struct TurnkeyRequestWrapper { + pub request: TurnkeyRequest, +} + +#[derive(Deserialize)] +pub struct TurnkeyRequest { + pub unsigned_payload: String, + pub chain: String, + #[serde(default)] + pub chain_metadata: Option, +} + +/// Tagged representation of chain metadata for unambiguous JSON deserialization. +/// +/// The generated `ChainMetadata` uses `serde(untagged)` on the inner oneof enum, which means +/// serde tries Ethereum first. A Solana payload with only `networkId` would be silently +/// decoded as `EthereumMetadata`. This wrapper uses an explicit `chain` discriminator. +#[derive(Deserialize)] +#[serde(tag = "chain", rename_all = "camelCase")] +pub enum ChainMetadataInput { + #[serde(rename = "CHAIN_ETHEREUM")] + Ethereum(EthereumMetadata), + #[serde(rename = "CHAIN_SOLANA")] + Solana(SolanaMetadata), +} + +impl From for ChainMetadata { + fn from(input: ChainMetadataInput) -> Self { + let metadata = match input { + ChainMetadataInput::Ethereum(eth) => chain_metadata::Metadata::Ethereum(eth), + ChainMetadataInput::Solana(sol) => chain_metadata::Metadata::Solana(sol), + }; + ChainMetadata { + metadata: Some(metadata), + } + } +} + +#[derive(Serialize)] +pub struct TurnkeyResponseWrapper { + pub response: TurnkeyResponse, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnkeyResponse { + pub parsed_transaction: TurnkeyParsedTransaction, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnkeyParsedTransaction { + pub payload: TurnkeyPayload, + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnkeyPayload { + pub signable_payload: String, + pub metadata_digest: String, + pub input_payload_digest: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TurnkeySignature { + pub message: String, + pub public_key: String, + pub scheme: String, + pub signature: String, +} + +pub fn error_response(msg: String) -> TurnkeyResponseWrapper { + TurnkeyResponseWrapper { + response: TurnkeyResponse { + parsed_transaction: TurnkeyParsedTransaction { + payload: TurnkeyPayload { + signable_payload: String::new(), + metadata_digest: EMPTY_SHA256.to_string(), + input_payload_digest: EMPTY_SHA256.to_string(), + }, + signature: None, + }, + }, + error: Some(msg), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn error_response_has_empty_sha256_digests() { + let resp = error_response("something broke".to_string()); + let payload = &resp.response.parsed_transaction.payload; + assert_eq!(payload.metadata_digest, EMPTY_SHA256); + assert_eq!(payload.input_payload_digest, EMPTY_SHA256); + assert!(payload.signable_payload.is_empty()); + assert_eq!(resp.error.as_deref(), Some("something broke")); + } + + #[test] + fn chain_metadata_input_solana_not_misread_as_ethereum() { + let json = r#"{"chain":"CHAIN_SOLANA","networkId":"solana-mainnet"}"#; + let parsed: ChainMetadataInput = serde_json::from_str(json).unwrap(); + assert!(matches!(parsed, ChainMetadataInput::Solana(_))); + } + + #[test] + fn chain_metadata_input_ethereum_deserializes() { + let json = r#"{"chain":"CHAIN_ETHEREUM","networkId":"ETHEREUM_MAINNET"}"#; + let parsed: ChainMetadataInput = serde_json::from_str(json).unwrap(); + assert!(matches!(parsed, ChainMetadataInput::Ethereum(_))); + } + + #[test] + fn ethereum_metadata_abi_mappings_defaults_when_omitted() { + let json = r#"{"networkId":"ETHEREUM_MAINNET"}"#; + let parsed: EthereumMetadata = serde_json::from_str(json).unwrap(); + assert!(parsed.abi_mappings.is_empty()); + } + + #[test] + fn solana_metadata_idl_mappings_defaults_when_omitted() { + let json = r#"{"networkId":"SOLANA_MAINNET"}"#; + let parsed: SolanaMetadata = serde_json::from_str(json).unwrap(); + assert!(parsed.idl_mappings.is_empty()); + } +} diff --git a/src/parser/gateway/src/x402_config.rs b/src/parser/gateway/src/x402_config.rs new file mode 100644 index 00000000..b7c7abac --- /dev/null +++ b/src/parser/gateway/src/x402_config.rs @@ -0,0 +1,501 @@ +//! x402 configuration loaded from env vars + named profiles. + +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; +use std::str::FromStr; +use std::time::Duration; +use url::Url; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum X402Profile { + Local, + PayAi, + Custom, +} + +impl FromStr for X402Profile { + type Err = ConfigError; + fn from_str(s: &str) -> Result { + match s { + "local" => Ok(X402Profile::Local), + "payai" => Ok(X402Profile::PayAi), + "custom" => Ok(X402Profile::Custom), + other => Err(ConfigError::UnknownProfile(other.to_string())), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayToAddress { + Evm(String), // 0x-prefixed 20-byte hex + Solana(String), // base58 32-byte pubkey +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PriceTagConfig { + pub network: String, // e.g. "base-sepolia", "base", "solana" + pub asset: String, // e.g. "USDC" + pub price_usd: Decimal, + pub pay_to: PayToAddress, + pub scheme: String, // "exact" (only supported v1; "upto" future) +} + +#[derive(Debug, Clone)] +pub struct X402Config { + pub profile: X402Profile, + pub facilitator_url: Url, + pub facilitator_timeout: Duration, + pub protocol_version: String, // "v2" + pub price_tags: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("unknown X402_PROFILE: {0}")] + UnknownProfile(String), + #[error("missing required env var: {0}")] + MissingVar(&'static str), + #[error("invalid env var {var}: {message}")] + Invalid { var: &'static str, message: String }, + #[error("X402_PRICE_TAGS_JSON parse error: {0}")] + JsonParse(String), +} + +// ── Wire types for X402_PRICE_TAGS_JSON deserialization ───────────────────── + +use serde::Deserialize; + +#[derive(Deserialize)] +struct PayToWire { + evm: Option, + solana: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PriceTagWire { + network: String, + asset: String, + price_usd: String, + pay_to: PayToWire, + #[serde(default = "default_scheme")] + scheme: String, +} + +fn default_scheme() -> String { + "exact".to_string() +} + +impl PayToWire { + fn into_pay_to(self) -> Result { + match (self.evm, self.solana) { + (Some(s), None) => Ok(PayToAddress::Evm(s)), + (None, Some(s)) => Ok(PayToAddress::Solana(s)), + _ => Err(ConfigError::Invalid { + var: "X402_PRICE_TAGS_JSON", + message: "payTo must specify exactly one of evm or solana".into(), + }), + } + } +} + +// ── X402Config env loader ──────────────────────────────────────────────────── + +impl X402Config { + /// Production entrypoint — reads the real process environment. + pub fn from_env() -> Result { + Self::from_lookup(|key| std::env::var(key).ok()) + } + + /// Test-friendly core — takes a closure that resolves env-var lookups. + /// All env reads in the loader go through this closure, so tests can + /// inject fixed values without mutating process state. + pub(crate) fn from_lookup(get: F) -> Result + where + F: Fn(&str) -> Option, + { + let profile = get("X402_PROFILE") + .unwrap_or_else(|| "local".to_string()) + .parse::()?; + + let facilitator_url = Self::load_facilitator_url(&get, profile)?; + let facilitator_timeout = Self::load_timeout(&get)?; + let protocol_version = get("X402_PROTOCOL_VERSION").unwrap_or_else(|| "v2".to_string()); + + let price_tags = if let Some(json) = get("X402_PRICE_TAGS_JSON") { + Self::parse_tags_json(&json)? + } else { + vec![Self::seeded_tag(&get, profile)?] + }; + + if price_tags.is_empty() { + return Err(ConfigError::Invalid { + var: "X402_PRICE_TAGS_JSON", + message: "must contain at least one tag".into(), + }); + } + + Ok(X402Config { + profile, + facilitator_url, + facilitator_timeout, + protocol_version, + price_tags, + }) + } + + fn load_facilitator_url(get: &F, profile: X402Profile) -> Result + where + F: Fn(&str) -> Option, + { + let s = match (get("X402_FACILITATOR_URL"), profile) { + (Some(s), _) => s, + (None, X402Profile::Local) => "http://127.0.0.1:8090".to_string(), + (None, X402Profile::PayAi) => "https://facilitator.payai.network".to_string(), + (None, X402Profile::Custom) => { + return Err(ConfigError::MissingVar("X402_FACILITATOR_URL")); + } + }; + Url::parse(&s).map_err(|e| ConfigError::Invalid { + var: "X402_FACILITATOR_URL", + message: e.to_string(), + }) + } + + fn load_timeout(get: &F) -> Result + where + F: Fn(&str) -> Option, + { + match get("X402_FACILITATOR_TIMEOUT_SECS") { + Some(s) => { + s.parse::() + .map(Duration::from_secs) + .map_err(|e| ConfigError::Invalid { + var: "X402_FACILITATOR_TIMEOUT_SECS", + message: e.to_string(), + }) + } + None => Ok(Duration::from_secs(5)), + } + } + + fn seeded_tag(get: &F, profile: X402Profile) -> Result + where + F: Fn(&str) -> Option, + { + let (network, price_str, default_payto): (&str, &str, Option) = match profile + { + X402Profile::Local => ( + "base-sepolia", + "0.0001", + Some(PayToAddress::Evm( + "0x000000000000000000000000000000000000dEaD".to_string(), + )), + ), + X402Profile::PayAi => ("base", "0.001", None), + X402Profile::Custom => { + return Err(ConfigError::MissingVar("X402_PRICE_TAGS_JSON")); + } + }; + + let price_usd = Decimal::from_str(price_str).map_err(|e| ConfigError::Invalid { + var: "(internal seed price)", + message: e.to_string(), + })?; + + let pay_to = match (get("X402_PAYTO"), default_payto) { + (Some(s), _) => Self::classify_payto(&s)?, + (None, Some(p)) => p, + (None, None) => return Err(ConfigError::MissingVar("X402_PAYTO")), + }; + + Ok(PriceTagConfig { + network: network.to_string(), + asset: "USDC".to_string(), + price_usd, + pay_to, + scheme: "exact".to_string(), + }) + } + + fn classify_payto(s: &str) -> Result { + if s.starts_with("0x") && s.len() == 42 { + Ok(PayToAddress::Evm(s.to_string())) + } else if !s.is_empty() && !s.starts_with("0x") { + Ok(PayToAddress::Solana(s.to_string())) + } else { + Err(ConfigError::Invalid { + var: "X402_PAYTO", + message: "not a recognizable EVM or Solana address".into(), + }) + } + } + + fn parse_tags_json(json: &str) -> Result, ConfigError> { + let wire: Vec = + serde_json::from_str(json).map_err(|e| ConfigError::JsonParse(e.to_string()))?; + wire.into_iter() + .map(|w| { + Ok(PriceTagConfig { + network: w.network, + asset: w.asset, + price_usd: Decimal::from_str(&w.price_usd).map_err(|e| { + ConfigError::Invalid { + var: "X402_PRICE_TAGS_JSON", + message: format!("priceUsd: {e}"), + } + })?, + pay_to: w.pay_to.into_pay_to()?, + scheme: w.scheme, + }) + }) + .collect() + } +} + +// ── X402Middleware builder ──────────────────────────────────────────────────── + +use std::sync::Arc; +use x402_axum::X402LayerBuilder; +use x402_axum::facilitator_client::FacilitatorClient; +use x402_axum::paygate::StaticPriceTags; +use x402_chain_eip155::chain::ChecksummedAddress; +use x402_chain_eip155::{KnownNetworkEip155, V2Eip155Exact}; +use x402_chain_solana::chain::Address as SolanaAddress; +use x402_chain_solana::{KnownNetworkSolana, V2SolanaExact}; +use x402_types::networks::USDC; +use x402_types::proto::v2; + +impl X402Config { + /// Build an `X402LayerBuilder` from the configured price tags. + /// + /// Returns an error if the facilitator URL is invalid, any address cannot be + /// parsed, the price produces arithmetic overflow, or a (payTo, network) + /// combination is unsupported. + pub fn build_middleware( + &self, + ) -> Result, Arc>, ConfigError> + { + let m = x402_axum::X402Middleware::try_new(self.facilitator_url.as_str()).map_err(|e| { + ConfigError::Invalid { + var: "X402_FACILITATOR_URL", + message: e.to_string(), + } + })?; + + // Convert all price tags to v2::PriceTag. + let tags: Vec = self + .price_tags + .iter() + .map(build_price_tag) + .collect::, _>>()?; + + // At least one tag is guaranteed by from_env validation, but handle + // the degenerate case safely rather than panicking. + let mut iter = tags.into_iter(); + let first = iter.next().ok_or_else(|| ConfigError::Invalid { + var: "X402_PRICE_TAGS_JSON", + message: "must contain at least one tag".into(), + })?; + + let mut builder = m.with_price_tag(first); + for tag in iter { + builder = builder.with_price_tag(tag); + } + + Ok(builder) + } +} + +/// Convert a single [`PriceTagConfig`] into a [`v2::PriceTag`]. +fn build_price_tag(tag: &PriceTagConfig) -> Result { + // USDC has 6 decimals on all supported networks. + // price_usd * 1_000_000 = atomic units. + let atomic = tag + .price_usd + .checked_mul(Decimal::from(1_000_000u64)) + .and_then(|d| d.round().to_u64()) + .ok_or_else(|| ConfigError::Invalid { + var: "priceUsd", + message: format!("price {} overflows USDC atomic units (u64)", tag.price_usd), + })?; + + match (&tag.pay_to, tag.network.as_str()) { + (PayToAddress::Evm(addr_s), "base-sepolia") => { + let addr: ChecksummedAddress = + addr_s + .parse() + .map_err( + |e: ::Err| ConfigError::Invalid { + var: "payTo.evm", + message: format!("invalid EVM address '{addr_s}': {e}"), + }, + )?; + Ok(V2Eip155Exact::price_tag( + addr, + USDC::base_sepolia().amount(atomic), + )) + } + (PayToAddress::Evm(addr_s), "base") => { + let addr: ChecksummedAddress = + addr_s + .parse() + .map_err( + |e: ::Err| ConfigError::Invalid { + var: "payTo.evm", + message: format!("invalid EVM address '{addr_s}': {e}"), + }, + )?; + Ok(V2Eip155Exact::price_tag(addr, USDC::base().amount(atomic))) + } + (PayToAddress::Solana(addr_s), "solana") => { + let addr: SolanaAddress = + addr_s + .parse() + .map_err(|e: ::Err| ConfigError::Invalid { + var: "payTo.solana", + message: format!("invalid Solana address '{addr_s}': {e}"), + })?; + Ok(V2SolanaExact::price_tag( + addr, + USDC::solana().amount(atomic), + )) + } + (pay_to, network) => Err(ConfigError::Invalid { + var: "X402_PRICE_TAGS_JSON", + message: format!( + "unsupported (payTo, network) combination: ({:?}, {network:?})", + match pay_to { + PayToAddress::Evm(_) => "evm", + PayToAddress::Solana(_) => "solana", + } + ), + }), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn profile_parses_local() { + assert_eq!("local".parse::().unwrap(), X402Profile::Local); + } + + #[test] + fn profile_parses_payai() { + assert_eq!("payai".parse::().unwrap(), X402Profile::PayAi); + } + + #[test] + fn profile_parses_custom() { + assert_eq!( + "custom".parse::().unwrap(), + X402Profile::Custom + ); + } + + #[test] + fn profile_rejects_unknown() { + assert!("nope".parse::().is_err()); + } + + // --- env-loader tests (no env mutation; pure closure-driven) --- + + fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option + 'a { + move |key| { + pairs.iter().find_map(|(k, v)| { + if *k == key { + Some((*v).to_string()) + } else { + None + } + }) + } + } + + #[test] + fn from_env_local_defaults() { + let cfg = X402Config::from_lookup(lookup(&[])).unwrap(); + assert_eq!(cfg.profile, X402Profile::Local); + assert_eq!(cfg.facilitator_url.as_str(), "http://127.0.0.1:8090/"); + assert_eq!(cfg.facilitator_timeout, Duration::from_secs(5)); + assert_eq!(cfg.protocol_version, "v2"); + assert_eq!(cfg.price_tags.len(), 1); + assert_eq!(cfg.price_tags[0].network, "base-sepolia"); + assert_eq!(cfg.price_tags[0].asset, "USDC"); + assert_eq!( + cfg.price_tags[0].price_usd, + Decimal::from_str("0.0001").unwrap() + ); + assert_eq!( + cfg.price_tags[0].pay_to, + PayToAddress::Evm("0x000000000000000000000000000000000000dEaD".to_string()) + ); + assert_eq!(cfg.price_tags[0].scheme, "exact"); + } + + #[test] + fn from_env_payai_requires_payto() { + let err = X402Config::from_lookup(lookup(&[("X402_PROFILE", "payai")])).unwrap_err(); + assert!(matches!(err, ConfigError::MissingVar("X402_PAYTO"))); + } + + #[test] + fn from_env_payai_with_payto() { + let cfg = X402Config::from_lookup(lookup(&[ + ("X402_PROFILE", "payai"), + ("X402_PAYTO", "0xabcdef0000000000000000000000000000000001"), + ])) + .unwrap(); + assert_eq!(cfg.profile, X402Profile::PayAi); + assert_eq!( + cfg.facilitator_url.as_str(), + "https://facilitator.payai.network/" + ); + assert_eq!(cfg.price_tags[0].network, "base"); + assert_eq!( + cfg.price_tags[0].price_usd, + Decimal::from_str("0.001").unwrap() + ); + assert_eq!( + cfg.price_tags[0].pay_to, + PayToAddress::Evm("0xabcdef0000000000000000000000000000000001".to_string()) + ); + } + + #[test] + fn from_env_custom_requires_facilitator_url() { + let err = X402Config::from_lookup(lookup(&[("X402_PROFILE", "custom")])).unwrap_err(); + assert!(matches!( + err, + ConfigError::MissingVar("X402_FACILITATOR_URL") + )); + } + + #[test] + fn from_env_tags_json_overrides_seed() { + let json = r#"[ + {"network":"base","asset":"USDC","priceUsd":"0.05","payTo":{"evm":"0x1111111111111111111111111111111111111111"},"scheme":"exact"}, + {"network":"solana","asset":"USDC","priceUsd":"0.05","payTo":{"solana":"EGBQqKn968sVv5cQh5Cr72pSTHfxsuzq7o7asqYB5uEV"},"scheme":"exact"} + ]"#; + let cfg = X402Config::from_lookup(lookup(&[("X402_PRICE_TAGS_JSON", json)])).unwrap(); + assert_eq!(cfg.price_tags.len(), 2); + assert_eq!(cfg.price_tags[0].network, "base"); + assert_eq!( + cfg.price_tags[0].price_usd, + Decimal::from_str("0.05").unwrap() + ); + assert_eq!(cfg.price_tags[1].network, "solana"); + assert!(matches!(cfg.price_tags[1].pay_to, PayToAddress::Solana(_))); + } + + #[test] + fn from_env_malformed_tags_json_rejected() { + let err = + X402Config::from_lookup(lookup(&[("X402_PRICE_TAGS_JSON", "not json")])).unwrap_err(); + assert!(matches!(err, ConfigError::JsonParse(_))); + } +} diff --git a/src/parser/mock-facilitator/Cargo.toml b/src/parser/mock-facilitator/Cargo.toml new file mode 100644 index 00000000..2b066d70 --- /dev/null +++ b/src/parser/mock-facilitator/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "mock_facilitator" +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] +axum = { version = "0.8", features = ["http1", "tokio", "json"], default-features = false } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "net"] } +serde = { workspace = true } +serde_json = { workspace = true } +rand = "0.8" + +[lib] +name = "mock_facilitator" +path = "src/lib.rs" + +[[bin]] +name = "mock_facilitator" +path = "src/main.rs" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } + +[lints] +workspace = true diff --git a/src/parser/mock-facilitator/build.rs b/src/parser/mock-facilitator/build.rs new file mode 100644 index 00000000..432f1abf --- /dev/null +++ b/src/parser/mock-facilitator/build.rs @@ -0,0 +1,7 @@ +fn main() { + println!( + "cargo:rustc-env=VERSION={}", + std::env::var("VERSION").unwrap_or_else(|_| "0.0.0-dev".to_string()) + ); + println!("cargo:rerun-if-env-changed=VERSION"); +} diff --git a/src/parser/mock-facilitator/src/lib.rs b/src/parser/mock-facilitator/src/lib.rs new file mode 100644 index 00000000..aad1777d --- /dev/null +++ b/src/parser/mock-facilitator/src/lib.rs @@ -0,0 +1,196 @@ +//! Mock x402 v2 facilitator — approves everything; dev/test only. + +use axum::{ + Json, Router, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyRequest { + pub payment_payload: Value, + pub payment_requirements: Value, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyResponse { + pub is_valid: bool, + pub payer: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SettleRequest { + pub payment_payload: Value, + pub payment_requirements: Value, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SettleResponse { + pub success: bool, + pub transaction: String, + pub network: String, + pub payer: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SupportedResponse { + pub kinds: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SupportedKind { + pub network: String, + pub asset: String, + pub scheme: String, +} + +pub fn router() -> Router { + Router::new() + .route("/verify", post(verify)) + .route("/settle", post(settle)) + .route("/supported", get(supported)) +} + +fn extract_payer(payload: &Value) -> String { + payload + .get("payer") + .and_then(|v| v.as_str()) + .unwrap_or("0xMOCKPAYER000000000000000000000000000000") + .to_string() +} + +fn extract_network(req: &Value) -> String { + req.get("network") + .and_then(|v| v.as_str()) + .unwrap_or("base-sepolia") + .to_string() +} + +async fn verify(Json(req): Json) -> Json { + Json(VerifyResponse { + is_valid: true, + payer: extract_payer(&req.payment_payload), + }) +} + +async fn settle(Json(req): Json) -> Json { + use rand::RngCore; + let mut buf = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut buf); + let tx = format!("0xmock{}", hex_encode(&buf)); + Json(SettleResponse { + success: true, + transaction: tx, + network: extract_network(&req.payment_requirements), + payer: extract_payer(&req.payment_payload), + }) +} + +async fn supported() -> Json { + Json(SupportedResponse { + kinds: vec![ + SupportedKind { + network: "base-sepolia".to_string(), + asset: "USDC".to_string(), + scheme: "exact".to_string(), + }, + SupportedKind { + network: "base".to_string(), + asset: "USDC".to_string(), + scheme: "exact".to_string(), + }, + SupportedKind { + network: "solana".to_string(), + asset: "USDC".to_string(), + scheme: "exact".to_string(), + }, + ], + }) +} + +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(HEX[(b >> 4) as usize] as char); + s.push(HEX[(b & 0x0f) as usize] as char); + } + s +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + #[tokio::test] + async fn verify_always_succeeds() { + let app = router(); + let body = serde_json::json!({ + "paymentPayload": { "payer": "0xabc" }, + "paymentRequirements": {} + }); + let resp = app + .oneshot( + Request::post("/verify") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["isValid"], true); + assert_eq!(v["payer"], "0xabc"); + } + + #[tokio::test] + async fn settle_returns_mock_tx_hash() { + let app = router(); + let body = serde_json::json!({ + "paymentPayload": { "payer": "0xdef" }, + "paymentRequirements": { "network": "base" } + }); + let resp = app + .oneshot( + Request::post("/settle") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["success"], true); + assert_eq!(v["network"], "base"); + assert_eq!(v["payer"], "0xdef"); + assert!(v["transaction"].as_str().unwrap().starts_with("0xmock")); + } + + #[tokio::test] + async fn supported_lists_three_networks() { + let app = router(); + let resp = app + .oneshot(Request::get("/supported").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["kinds"].as_array().unwrap().len(), 3); + } +} diff --git a/src/parser/mock-facilitator/src/main.rs b/src/parser/mock-facilitator/src/main.rs new file mode 100644 index 00000000..1fe99008 --- /dev/null +++ b/src/parser/mock-facilitator/src/main.rs @@ -0,0 +1,39 @@ +// TODO(#231): Remove these exemptions and fix violations in a follow-up PR. +#![allow(clippy::unwrap_used)] +#![allow(clippy::expect_used)] +#![allow(clippy::panic)] + +use std::net::SocketAddr; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let port: u16 = std::env::var("MOCK_FACILITATOR_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8090); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + println!("mock_facilitator {} listening on {addr}", env!("VERSION")); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, mock_facilitator::router()) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +async fn shutdown_signal() { + let ctrl_c = tokio::signal::ctrl_c(); + #[cfg(unix)] + { + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to register SIGTERM handler"); + tokio::select! { + _ = ctrl_c => {} + _ = sigterm.recv() => {} + } + } + #[cfg(not(unix))] + ctrl_c.await.expect("failed to listen for ctrl-c"); + println!("Shutting down mock_facilitator"); +} From 7141df54af6899fb711c26064cdd5cb080d36798 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:38:23 +0000 Subject: [PATCH 03/11] fix: address x402 review feedback and stabilize gateway tests Agent-Logs-Url: https://github.com/anchorageoss/visualsign-parser/sessions/ea819765-afae-4e0f-a3c6-ebaa40e503ed Co-authored-by: andrestielau <115989486+andrestielau@users.noreply.github.com> --- images/parser_gateway/Containerfile | 2 - scripts/x402-demo.sh | 8 ++-- src/integration/tests/x402_gateway_test.rs | 12 ++++-- src/parser/gateway/src/main.rs | 50 ++++++++++++---------- src/parser/gateway/src/x402_config.rs | 49 ++++++++++++++++++--- 5 files changed, 83 insertions(+), 38 deletions(-) diff --git a/images/parser_gateway/Containerfile b/images/parser_gateway/Containerfile index cd108da7..4c8816bb 100644 --- a/images/parser_gateway/Containerfile +++ b/images/parser_gateway/Containerfile @@ -28,6 +28,4 @@ EOF # Use busybox as a base so we can easily cp the pivot binary if needed FROM stagex/core-busybox:1.36.1@sha256:cac5d773db1c69b832d022c469ccf5f52daf223b91166e6866d42d6983a3b374 AS package -ENV X402_PROFILE=local -ENV X402_FACILITATOR_URL=http://mock_facilitator:8090 COPY --from=build /rootfs/. . diff --git a/scripts/x402-demo.sh b/scripts/x402-demo.sh index 86be4784..d6804e75 100755 --- a/scripts/x402-demo.sh +++ b/scripts/x402-demo.sh @@ -6,7 +6,7 @@ # scenario with commentary. # # Run from the repo root: ./scripts/x402-demo.sh -# Requirements: bash, curl, jq, base64, cargo. No network needed. +# Requirements: bash, curl, jq, base64, cargo, lsof, make. No network needed. # set -euo pipefail @@ -36,12 +36,12 @@ pause() { sleep "${DEMO_PAUSE:-0.4}"; } chapter "Preflight" "Make sure we have everything we need before starting." -for tool in curl jq base64 cargo; do +for tool in curl jq base64 cargo lsof make; do if ! command -v "$tool" >/dev/null 2>&1; then fail "missing tool: $tool" fi done -ok "curl, jq, base64, cargo all present" +ok "curl, jq, base64, cargo, lsof, make all present" MOCK_BIN="src/target/debug/mock_facilitator" GRPC_BIN="src/target/debug/parser_grpc_server" @@ -180,7 +180,7 @@ build_payment_signature() { requirements=$(printf %s "$pr_b64" | base64 -d 2>/dev/null | jq '.accepts[0]') jq -nc --argjson req "$requirements" \ '{x402Version: 2, accepted: $req, payload: {payer: "0xDEM0DEM0DEM0DEM0DEM0DEM0DEM0DEM0DEM0DEM0"}}' \ - | base64 -w0 + | base64 | tr -d '\n' } # Pretty-print a JSON snippet, trimmed to N lines. diff --git a/src/integration/tests/x402_gateway_test.rs b/src/integration/tests/x402_gateway_test.rs index ee6c4ef5..7f0eeead 100644 --- a/src/integration/tests/x402_gateway_test.rs +++ b/src/integration/tests/x402_gateway_test.rs @@ -1,21 +1,20 @@ //! End-to-end: mock_facilitator + parser_grpc_server + parser_gateway, //! exercising the v2 x402-gated route alongside the v1 open route. -//! -//! Run with: -//! cargo test -p integration --test x402_gateway_test -- --test-threads=1 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::net::TcpListener; use std::process::{Child, Command, Stdio}; use std::time::Duration; +use tokio::sync::Mutex; use tokio::time::sleep; -// ── Ports used by all five tests (fixed; tests run single-threaded) ─────────── +// ── Ports used by all five tests (fixed; serialized via TEST_MUTEX) ──────────── const MOCK_PORT: u16 = 18090; // Note: parser_grpc_server always binds 0.0.0.0:44020 (hardcoded in binary). // The gateway is pointed at that address via GRPC_ADDR env var. const GW_PORT: u16 = 18080; +static TEST_MUTEX: Mutex<()> = Mutex::const_new(()); // ── Binary helpers ──────────────────────────────────────────────────────────── @@ -227,6 +226,7 @@ const ETH_TX_HEX: &str = "0xf86c808504a817c8008252089435353535353535353535353535 /// `Payment-Required` header (base64 JSON), not in the response body. #[tokio::test] async fn path1_v2_without_payment_returns_402() { + let _guard = TEST_MUTEX.lock().await; let _p = start_procs().await; let body = serde_json::json!({ @@ -282,6 +282,7 @@ async fn path1_v2_without_payment_returns_402() { /// We first probe the 402 to learn the exact requirements, then echo them back in `accepted`. #[tokio::test] async fn path2_v2_with_valid_payment_returns_200() { + let _guard = TEST_MUTEX.lock().await; let _p = start_procs().await; // Fetch actual requirements from the 402 response. @@ -317,6 +318,7 @@ async fn path2_v2_with_valid_payment_returns_200() { /// payload → 400. The gRPC parser rejects it before settlement. #[tokio::test] async fn path3_v2_valid_payment_bad_tx_returns_400() { + let _guard = TEST_MUTEX.lock().await; let _p = start_procs().await; let requirements = fetch_v2_requirements().await; @@ -346,6 +348,7 @@ async fn path3_v2_valid_payment_bad_tx_returns_400() { /// Path 4: POST /visualsign/api/v1/parse without payment header → 200 (open route). #[tokio::test] async fn path4_v1_without_payment_returns_200() { + let _guard = TEST_MUTEX.lock().await; let _p = start_procs().await; let body = serde_json::json!({ @@ -368,6 +371,7 @@ async fn path4_v1_without_payment_returns_200() { /// Path 5: GET /health → 200 with no authentication. #[tokio::test] async fn path5_health_open() { + let _guard = TEST_MUTEX.lock().await; let _p = start_procs().await; let resp = reqwest::get(format!("http://127.0.0.1:{GW_PORT}/health")) diff --git a/src/parser/gateway/src/main.rs b/src/parser/gateway/src/main.rs index 0e3c53bd..9a54d9be 100644 --- a/src/parser/gateway/src/main.rs +++ b/src/parser/gateway/src/main.rs @@ -40,23 +40,7 @@ async fn main() -> Result<(), Box> { health_client, }; - let x402_cfg = - parser_gateway::x402_config::X402Config::from_env().expect("invalid X402 configuration"); - let x402_middleware = x402_cfg - .build_middleware() - .expect("invalid X402 price tags"); - - if let Err(e) = probe_facilitator(&x402_cfg.facilitator_url, x402_cfg.facilitator_timeout).await - { - return Err(format!( - "x402 facilitator probe failed for {}: {e}", - x402_cfg.facilitator_url - ) - .into()); - } - println!("x402 facilitator probe OK"); - - let app = Router::new() + let mut app = Router::new() .route( "/health", get(parser_gateway::handlers::health::health_handler), @@ -64,11 +48,33 @@ async fn main() -> Result<(), Box> { .route( "/visualsign/api/v1/parse", post(parser_gateway::handlers::parse::parse_handler), - ) - .route( - "/visualsign/api/v2/parse", - post(parser_gateway::handlers::parse::parse_handler).layer(x402_middleware), - ) + ); + + match parser_gateway::x402_config::X402Config::from_env() { + Ok(x402_cfg) => match x402_cfg.build_middleware() { + Ok(x402_middleware) => { + if let Err(e) = + probe_facilitator(&x402_cfg.facilitator_url, x402_cfg.facilitator_timeout).await + { + eprintln!( + "WARNING: x402 disabled; facilitator probe failed for {}: {e}", + x402_cfg.facilitator_url + ); + } else { + println!("x402 facilitator probe OK"); + app = app.route( + "/visualsign/api/v2/parse", + post(parser_gateway::handlers::parse::parse_handler) + .layer(x402_middleware), + ); + } + } + Err(e) => eprintln!("WARNING: x402 disabled; invalid x402 price tags: {e}"), + }, + Err(e) => eprintln!("WARNING: x402 disabled; invalid x402 configuration: {e}"), + } + + let app = app .layer(DefaultBodyLimit::max(GRPC_MAX_RECV_MSG_SIZE)) .with_state(state); diff --git a/src/parser/gateway/src/x402_config.rs b/src/parser/gateway/src/x402_config.rs index b7c7abac..e516bc41 100644 --- a/src/parser/gateway/src/x402_config.rs +++ b/src/parser/gateway/src/x402_config.rs @@ -37,7 +37,7 @@ pub struct PriceTagConfig { pub asset: String, // e.g. "USDC" pub price_usd: Decimal, pub pay_to: PayToAddress, - pub scheme: String, // "exact" (only supported v1; "upto" future) + pub scheme: PriceScheme, // currently only "exact" is supported for v2 tags } #[derive(Debug, Clone)] @@ -86,6 +86,25 @@ fn default_scheme() -> String { "exact".to_string() } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PriceScheme { + Exact, +} + +impl FromStr for PriceScheme { + type Err = ConfigError; + + fn from_str(s: &str) -> Result { + match s { + "exact" => Ok(Self::Exact), + other => Err(ConfigError::Invalid { + var: "X402_PRICE_TAGS_JSON", + message: format!("unsupported scheme '{other}'; only 'exact' is supported"), + }), + } + } +} + impl PayToWire { fn into_pay_to(self) -> Result { match (self.evm, self.solana) { @@ -214,7 +233,7 @@ impl X402Config { asset: "USDC".to_string(), price_usd, pay_to, - scheme: "exact".to_string(), + scheme: PriceScheme::Exact, }) } @@ -246,7 +265,7 @@ impl X402Config { } })?, pay_to: w.pay_to.into_pay_to()?, - scheme: w.scheme, + scheme: w.scheme.parse()?, }) }) .collect() @@ -259,10 +278,12 @@ use std::sync::Arc; use x402_axum::X402LayerBuilder; use x402_axum::facilitator_client::FacilitatorClient; use x402_axum::paygate::StaticPriceTags; +use x402_chain_eip155::KnownNetworkEip155; +use x402_chain_eip155::V2Eip155Exact; use x402_chain_eip155::chain::ChecksummedAddress; -use x402_chain_eip155::{KnownNetworkEip155, V2Eip155Exact}; +use x402_chain_solana::KnownNetworkSolana; +use x402_chain_solana::V2SolanaExact; use x402_chain_solana::chain::Address as SolanaAddress; -use x402_chain_solana::{KnownNetworkSolana, V2SolanaExact}; use x402_types::networks::USDC; use x402_types::proto::v2; @@ -309,6 +330,13 @@ impl X402Config { /// Convert a single [`PriceTagConfig`] into a [`v2::PriceTag`]. fn build_price_tag(tag: &PriceTagConfig) -> Result { + if tag.scheme != PriceScheme::Exact { + return Err(ConfigError::Invalid { + var: "X402_PRICE_TAGS_JSON", + message: "unsupported scheme; only 'exact' is supported".into(), + }); + } + // USDC has 6 decimals on all supported networks. // price_usd * 1_000_000 = atomic units. let atomic = tag @@ -434,7 +462,7 @@ mod tests { cfg.price_tags[0].pay_to, PayToAddress::Evm("0x000000000000000000000000000000000000dEaD".to_string()) ); - assert_eq!(cfg.price_tags[0].scheme, "exact"); + assert_eq!(cfg.price_tags[0].scheme, PriceScheme::Exact); } #[test] @@ -498,4 +526,13 @@ mod tests { X402Config::from_lookup(lookup(&[("X402_PRICE_TAGS_JSON", "not json")])).unwrap_err(); assert!(matches!(err, ConfigError::JsonParse(_))); } + + #[test] + fn from_env_rejects_unsupported_scheme() { + let json = r#"[ + {"network":"base","asset":"USDC","priceUsd":"0.05","payTo":{"evm":"0x1111111111111111111111111111111111111111"},"scheme":"upto"} + ]"#; + let err = X402Config::from_lookup(lookup(&[("X402_PRICE_TAGS_JSON", json)])).unwrap_err(); + assert!(matches!(err, ConfigError::Invalid { .. })); + } } From 35b6938f6de1c67ec41e31e0adab4840b8aded3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 20:42:37 +0000 Subject: [PATCH 04/11] test: document x402 integration test serialization lock Agent-Logs-Url: https://github.com/anchorageoss/visualsign-parser/sessions/ea819765-afae-4e0f-a3c6-ebaa40e503ed Co-authored-by: andrestielau <115989486+andrestielau@users.noreply.github.com> --- src/integration/tests/x402_gateway_test.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/integration/tests/x402_gateway_test.rs b/src/integration/tests/x402_gateway_test.rs index 7f0eeead..aa265c44 100644 --- a/src/integration/tests/x402_gateway_test.rs +++ b/src/integration/tests/x402_gateway_test.rs @@ -14,6 +14,8 @@ const MOCK_PORT: u16 = 18090; // Note: parser_grpc_server always binds 0.0.0.0:44020 (hardcoded in binary). // The gateway is pointed at that address via GRPC_ADDR env var. const GW_PORT: u16 = 18080; +/// Serializes these fixed-port tests to avoid cross-test port binding races +/// when the integration test binary is executed with multiple test threads. static TEST_MUTEX: Mutex<()> = Mutex::const_new(()); // ── Binary helpers ──────────────────────────────────────────────────────────── From 9c61710cd0c930ca753787b7e21191f7360d7af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Stielau?= Date: Fri, 15 May 2026 11:36:03 +0000 Subject: [PATCH 05/11] fix: streamline route definition for parse handler in main function --- src/parser/gateway/src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/parser/gateway/src/main.rs b/src/parser/gateway/src/main.rs index 9a54d9be..2520f6b0 100644 --- a/src/parser/gateway/src/main.rs +++ b/src/parser/gateway/src/main.rs @@ -64,8 +64,7 @@ async fn main() -> Result<(), Box> { println!("x402 facilitator probe OK"); app = app.route( "/visualsign/api/v2/parse", - post(parser_gateway::handlers::parse::parse_handler) - .layer(x402_middleware), + post(parser_gateway::handlers::parse::parse_handler).layer(x402_middleware), ); } } From 970f2f04962f59744dea56672a3c096a2bd1334a Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Fri, 15 May 2026 20:14:30 +0000 Subject: [PATCH 06/11] feat(x402): TVC attestation + payai Solana devnet E2E + containerized dev Build on the v2 x402 gating layer with the pieces needed to ship in Turnkey's TVC stack and exercise end-to-end against the real payai facilitator on Solana devnet. Gateway - `parser_gateway/src/attestation.rs` verifies every parse response against a launch-arg-pinned `qos_p256::P256Public`; on mismatch the handler returns 502 so x402-axum's settle-on-success contract skips `/settle` (unattested responses are never charged). - New env: `X402_TVC_VERIFIER_PUBKEY_HEX` / `_FILE`. Fail-closed in non-local profiles; warn-and-bypass under `X402_PROFILE=local`. - `x402_config.rs` learns `solana-devnet` and a profile-overriding `X402_NETWORK` knob; mock_facilitator advertises `solana-devnet`. Tests - `tests/x402_gateway_test.rs` gains path 6: pinned pubkey mismatch -> 502 and mock_facilitator `/debug/settle_count` stays at 0. - New `tests/x402_payai_devnet_test.rs` (`#[ignore]` + `X402_E2E=1`) runs the full pay -> parse -> verify cycle against `https://facilitator.payai.network` on devnet. - Reproducible buyer wallet at `integration/fixtures/devnet/wallet.seed` (non-secret, devnet only), address `x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW`. - Minimal test-only Rust x402 Solana client in `integration/src/solana_x402_client.rs`. We deliberately do NOT publish a production Rust client; payai's `x402-solana` TS package is the supported reference. Containerization / TVC - New `images/parser_grpc_server/Containerfile` (stagex, mirrors the existing gateway image). - `compose.mock.yml` and `compose.payai.yml` at the repo root consume the same stagex OCI images that ship to GHCR. - `make dev-up-mock`, `make dev-up-payai`, `make dev-down` targets. - `.github/workflows/stagex.yml` builds `parser_grpc_server` and emits the TVC deployment block for `parser_gateway` too, so release notes pin both halves of the trust pair (enclave + host gateway). TS demo + docs - `scripts/x402-solana-devnet-demo.ts` (+ `scripts/package.json`) drives the gateway with a real x402 payment, independently verifies the response signature with `@noble/curves/p256`. - `src/parser/gateway/README.md` documents env vars, profiles, the attestation model, compose flows, and the devnet wallet. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/stagex.yml | 67 ++-- Makefile | 40 +++ compose.mock.yml | 44 +++ compose.payai.yml | 46 +++ images/parser_grpc_server/Containerfile | 31 ++ scripts/package.json | 24 ++ scripts/x402-solana-devnet-demo.ts | 280 ++++++++++++++++ src/Cargo.lock | 11 + src/integration/Cargo.toml | 8 + src/integration/fixtures/devnet/README.md | 32 ++ .../fixtures/devnet/wallet.address | 1 + src/integration/fixtures/devnet/wallet.seed | 1 + src/integration/src/solana_x402_client.rs | 253 ++++++++++++++ src/integration/tests/x402_gateway_test.rs | 91 ++++- .../tests/x402_payai_devnet_test.rs | 313 ++++++++++++++++++ src/parser/gateway/Cargo.toml | 9 + src/parser/gateway/README.md | 132 ++++++++ src/parser/gateway/src/attestation.rs | 286 ++++++++++++++++ src/parser/gateway/src/handlers/parse.rs | 51 ++- src/parser/gateway/src/lib.rs | 1 + src/parser/gateway/src/main.rs | 41 +++ src/parser/gateway/src/state.rs | 8 + src/parser/gateway/src/x402_config.rs | 116 ++++++- src/parser/mock-facilitator/src/lib.rs | 97 +++++- 24 files changed, 1917 insertions(+), 66 deletions(-) create mode 100644 compose.mock.yml create mode 100644 compose.payai.yml create mode 100644 images/parser_grpc_server/Containerfile create mode 100644 scripts/package.json create mode 100644 scripts/x402-solana-devnet-demo.ts create mode 100644 src/integration/fixtures/devnet/README.md create mode 100644 src/integration/fixtures/devnet/wallet.address create mode 100644 src/integration/fixtures/devnet/wallet.seed create mode 100644 src/integration/src/solana_x402_client.rs create mode 100644 src/integration/tests/x402_payai_devnet_test.rs create mode 100644 src/parser/gateway/README.md create mode 100644 src/parser/gateway/src/attestation.rs diff --git a/.github/workflows/stagex.yml b/.github/workflows/stagex.yml index 8a7a4b61..f0cc56be 100644 --- a/.github/workflows/stagex.yml +++ b/.github/workflows/stagex.yml @@ -47,6 +47,7 @@ jobs: - name: parser_cli label: "parser_cli (Solana)" - name: parser_gateway + - name: parser_grpc_server steps: - name: Checkout sources uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -135,9 +136,10 @@ jobs: docker image push --all-tags "ghcr.io/anchorageoss/${{ matrix.target.name }}" - name: TVC deployment details - if: matrix.target.name == 'parser_app' + if: matrix.target.name == 'parser_app' || matrix.target.name == 'parser_gateway' shell: bash env: + TARGET_NAME: ${{ matrix.target.name }} SEMVER_TAG: ${{ steps.build.outputs.semver_tag }} SHA_TAG: ${{ steps.build.outputs.sha_tag }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -146,26 +148,33 @@ jobs: # the always-unique commit SHA on PR/branch builds where SEMVER # isn't reproducible. Both reference the same digest below. tvc_tag="${SEMVER_TAG:-$SHA_TAG}" - container_url="ghcr.io/anchorageoss/parser_app:${tvc_tag}" + container_url="ghcr.io/anchorageoss/${TARGET_NAME}:${tvc_tag}" container_digest=$(docker inspect --format='{{index .RepoDigests 0}}' "${container_url}" | cut -d '@' -f 2) - docker create --name tmp-extract "${container_url}" /bin/true - docker cp tmp-extract:/parser_app /tmp/binary - docker rm tmp-extract - digest=$(sha256sum /tmp/binary | awk '{print $1}') + docker create --name tmp-extract-${TARGET_NAME} "${container_url}" /bin/true + docker cp "tmp-extract-${TARGET_NAME}:/${TARGET_NAME}" "/tmp/${TARGET_NAME}" + docker rm "tmp-extract-${TARGET_NAME}" + digest=$(sha256sum "/tmp/${TARGET_NAME}" | awk '{print $1}') pinned_url="${container_url}@${container_digest}" # Hardcoded: TVC currently only supports this QOS version. qos_version="v2026.2.6" - # Build the deployment block once, then fan it out to both the run - # summary and (when a matching GitHub release exists) the release - # body. Operators reading either surface get the same paste-ready - # values. - deploy_md="${RUNNER_TEMP}/tvc_deploy.md" + # parser_app is the enclave-side binary (signs responses). + # parser_gateway is the host-side binary (verifies the signature + # against a pinned pubkey + handles x402 settlement). Operators + # need both digests pinned to actually establish the trust pair. + if [ "${TARGET_NAME}" = "parser_app" ]; then + role_note=$'\nThe enclave-side binary. Its ephemeral public key, signed by QOS during boot, becomes the gateway-side `X402_TVC_VERIFIER_PUBKEY_HEX`.' + else + role_note=$'\nThe host-side gateway. Pin the matching `parser_app` digest as the enclave it talks to; the gateway verifies every response against the enclave\'s ephemeral pubkey (pinned at boot via `X402_TVC_VERIFIER_PUBKEY_HEX` or `X402_TVC_VERIFIER_PUBKEY_FILE`).' + fi + + deploy_md="${RUNNER_TEMP}/tvc_deploy_${TARGET_NAME}.md" { - echo "## TVC Deployment Details" + echo "## TVC Deployment Details — ${TARGET_NAME}" + echo "${role_note}" echo "" echo "- **Container Image URL**: \`${pinned_url}\`" - echo "- **Executable path**: \`/parser_app\`" + echo "- **Executable path**: \`/${TARGET_NAME}\`" echo "- **Expected Executable Digest**: \`sha256:${digest}\`" echo "- **QOS Version**: \`${qos_version}\`" echo "" @@ -174,7 +183,7 @@ jobs: echo '```bash' echo "export CONTAINER_URL=\"${pinned_url}\"" echo 'cid=$(docker create "$CONTAINER_URL" /bin/true)' - echo 'docker cp "$cid:/parser_app" /tmp/binary' + echo "docker cp \"\$cid:/${TARGET_NAME}\" /tmp/binary" echo 'docker rm "$cid"' echo 'sha256sum /tmp/binary' echo '```' @@ -187,7 +196,7 @@ jobs: echo 'tvc deploy init -o tvc-deploy.json' echo '# Edit tvc-deploy.json to set:' echo "# \"pivotContainerImageUrl\": \"${pinned_url}\"" - echo '# "pivotPath": "/parser_app"' + echo "# \"pivotPath\": \"/${TARGET_NAME}\"" echo "# \"expectedPivotDigest\": \"sha256:${digest}\"" echo "# \"qosVersion\": \"${qos_version}\"" echo '# "appId": ""' @@ -198,32 +207,28 @@ jobs: cat "$deploy_md" >> "$GITHUB_STEP_SUMMARY" # Mirror onto the GitHub release for this semver, when it exists. - # PR/branch builds have no SEMVER_TAG and no matching release; - # push-triggered stagex on main may race release.yml (which creates - # the release for the same commit); in either case just skip — the - # release-dispatched stagex run will populate it. - # - # Re-running stagex for the same release (manual UI re-run) must be - # idempotent: strip any existing block bracketed by our sentinel - # comments before appending the freshly-built one. The sentinels - # render as nothing in the release body. + # Each target uses its own sentinel pair so parser_app and + # parser_gateway can publish independently without clobbering each + # other when stagex runs them in parallel. + begin_sentinel="" + end_sentinel="" if [ -n "$SEMVER_TAG" ] && gh release view "$SEMVER_TAG" >/dev/null 2>&1; then existing_body=$(gh release view "$SEMVER_TAG" --json body --jq .body) - preserved=$(printf '%s\n' "$existing_body" | awk ' - // { skipping = 1; next } - // { skipping = 0; next } + preserved=$(printf '%s\n' "$existing_body" | awk -v b="$begin_sentinel" -v e="$end_sentinel" ' + $0 == b { skipping = 1; next } + $0 == e { skipping = 0; next } !skipping ') - new_body="${RUNNER_TEMP}/release_notes.md" + new_body="${RUNNER_TEMP}/release_notes_${TARGET_NAME}.md" { printf '%s\n\n' "$preserved" - echo "" + echo "$begin_sentinel" cat "$deploy_md" - echo "" + echo "$end_sentinel" } > "$new_body" gh release edit "$SEMVER_TAG" --notes-file "$new_body" \ --repo "${GITHUB_REPOSITORY}" - echo "Updated release $SEMVER_TAG with TVC deployment details." + echo "Updated release $SEMVER_TAG with TVC deployment details for ${TARGET_NAME}." else echo "No release to update (SEMVER_TAG='${SEMVER_TAG:-}'); skipping release-notes update." fi diff --git a/Makefile b/Makefile index 964a3db3..13da9d07 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,10 @@ out/parser_gateway/index.json: \ $(shell git ls-files images/parser_gateway src) $(call build,parser_gateway) +out/parser_grpc_server/index.json: \ + $(shell git ls-files images/parser_grpc_server src) + $(call build,parser_grpc_server) + out/mock_facilitator/index.json: \ $(shell git ls-files images/mock_facilitator src) $(call build,mock_facilitator) @@ -18,8 +22,44 @@ out/mock_facilitator/index.json: \ non-oci-docker-images: docker buildx build --load --tag anchorageoss-visualsign-parser/parser_app -f images/parser_app/Containerfile . docker buildx build --load --tag anchorageoss-visualsign-parser/parser_gateway -f images/parser_gateway/Containerfile . + docker buildx build --load --tag anchorageoss-visualsign-parser/parser_grpc_server -f images/parser_grpc_server/Containerfile . docker buildx build --load --tag anchorageoss-visualsign-parser/mock_facilitator -f images/mock_facilitator/Containerfile . +# ── Local dev stacks ──────────────────────────────────────────────────────── +# +# `dev-up-mock` — offline stack: parser_grpc_server + parser_gateway pointed +# at the bundled mock_facilitator. Useful when network egress +# to facilitator.payai.network isn't available. +# `dev-up-payai` — real-facilitator stack: parser_grpc_server + parser_gateway +# pointed at https://facilitator.payai.network with +# X402_NETWORK=solana-devnet. Requires public egress. +# Set X402_TVC_VERIFIER_PUBKEY_HEX before running this +# target; otherwise the gateway fail-closes. +# Both compose files consume the locally-built stagex images. Build them +# first with `make non-oci-docker-images`. +.PHONY: dev-up-mock dev-up-payai dev-down dev-logs + +dev-up-mock: non-oci-docker-images + docker compose -f compose.mock.yml up -d + +dev-up-payai: non-oci-docker-images + @if [ -z "$$X402_TVC_VERIFIER_PUBKEY_HEX" ]; then \ + echo "ERROR: X402_TVC_VERIFIER_PUBKEY_HEX must be set (the gateway fail-closes without it for X402_PROFILE=payai)."; \ + exit 1; \ + fi + docker compose -f compose.payai.yml up -d + +dev-down: + -docker compose -f compose.mock.yml down --remove-orphans 2>/dev/null || true + -docker compose -f compose.payai.yml down --remove-orphans 2>/dev/null || true + +dev-logs: + @if docker compose -f compose.payai.yml ps -q 2>/dev/null | grep -q .; then \ + docker compose -f compose.payai.yml logs -f --tail=100; \ + else \ + docker compose -f compose.mock.yml logs -f --tail=100; \ + fi + define build_context $$( \ mkdir -p out; \ diff --git a/compose.mock.yml b/compose.mock.yml new file mode 100644 index 00000000..d61686ed --- /dev/null +++ b/compose.mock.yml @@ -0,0 +1,44 @@ +# Local-dev x402 stack with the bundled mock_facilitator (no network egress +# required). Builds the same stagex-built OCI images used in production — +# `make non-oci-docker-images` loads them into the docker daemon first. +# +# Use: `make dev-up-mock` (or `docker compose -f compose.mock.yml up`) +# Tear down: `make dev-down` + +services: + mock_facilitator: + image: anchorageoss-visualsign-parser/mock_facilitator:latest + command: ["/mock_facilitator"] + environment: + MOCK_FACILITATOR_PORT: "8090" + ports: + - "8090:8090" + + parser_grpc_server: + image: anchorageoss-visualsign-parser/parser_grpc_server:latest + command: ["/parser_grpc_server"] + environment: + EPHEMERAL_FILE: "/etc/parser/ephemeral.secret" + volumes: + # The test fixture key is fine for local dev; production TVC injects a + # real provisioned ephemeral key. NEVER ship this fixture into prod. + - ./src/integration/fixtures/ephemeral.secret:/etc/parser/ephemeral.secret:ro + ports: + - "44020:44020" + + parser_gateway: + image: anchorageoss-visualsign-parser/parser_gateway:latest + command: ["/parser_gateway"] + depends_on: + - mock_facilitator + - parser_grpc_server + environment: + GATEWAY_PORT: "8080" + GRPC_ADDR: "http://parser_grpc_server:44020" + X402_PROFILE: "local" + X402_FACILITATOR_URL: "http://mock_facilitator:8090" + # X402_TVC_VERIFIER_PUBKEY_HEX intentionally omitted: local profile + # allows running without attestation. Set this env var here to opt + # into verification locally (the gateway will fail on mismatch). + ports: + - "8080:8080" diff --git a/compose.payai.yml b/compose.payai.yml new file mode 100644 index 00000000..020605a4 --- /dev/null +++ b/compose.payai.yml @@ -0,0 +1,46 @@ +# Local-dev x402 stack against the **real** payai facilitator on Solana +# **devnet**. Builds the same stagex-built OCI images used in production — +# `make non-oci-docker-images` loads them into the docker daemon first. +# +# Required env at host shell level (set before `make dev-up-payai`): +# X402_PAYTO= +# — where payai will route the USDC. For local testing this can be +# the buyer wallet itself for a self-transfer. +# X402_TVC_VERIFIER_PUBKEY_HEX= +# — pinned ephemeral key the gateway will verify against. Without this +# the gateway fail-closes (X402_PROFILE != local). +# +# To target a TVC-deployed gateway image instead of a local build, replace +# the `image:` field with the GHCR digest-pinned form, e.g. +# image: ghcr.io/anchorageoss/parser_gateway:v0.1.2@sha256: + +services: + parser_grpc_server: + image: anchorageoss-visualsign-parser/parser_grpc_server:latest + command: ["/parser_grpc_server"] + environment: + EPHEMERAL_FILE: "/etc/parser/ephemeral.secret" + volumes: + # Local-dev only. Production TVC provisions the real key via QoS host + # boot; the public half ends up as X402_TVC_VERIFIER_PUBKEY_HEX on the + # gateway side. + - ./src/integration/fixtures/ephemeral.secret:/etc/parser/ephemeral.secret:ro + ports: + - "44020:44020" + + parser_gateway: + image: anchorageoss-visualsign-parser/parser_gateway:latest + command: ["/parser_gateway"] + depends_on: + - parser_grpc_server + environment: + GATEWAY_PORT: "8080" + GRPC_ADDR: "http://parser_grpc_server:44020" + X402_PROFILE: "payai" + X402_NETWORK: "solana-devnet" + X402_FACILITATOR_URL: "https://facilitator.payai.network" + X402_FACILITATOR_TIMEOUT_SECS: "10" + X402_PAYTO: "${X402_PAYTO:?X402_PAYTO must be set in your shell before docker compose up}" + X402_TVC_VERIFIER_PUBKEY_HEX: "${X402_TVC_VERIFIER_PUBKEY_HEX:?required: pin the TVC enclave's P256 public key here}" + ports: + - "8080:8080" diff --git a/images/parser_grpc_server/Containerfile b/images/parser_grpc_server/Containerfile new file mode 100644 index 00000000..1cf3a1a4 --- /dev/null +++ b/images/parser_grpc_server/Containerfile @@ -0,0 +1,31 @@ +FROM stagex/pallet-rust:1.88.0@sha256:b9021d2b75eac64fe8b931d96dde63ef11792e5023cee77c3471ccc34a95a377 AS build + +# Rust configuration +ENV RUSTFLAGS='-C target-feature=+crt-static' +ENV CARGOFLAGS='--target x86_64-unknown-linux-musl --no-default-features --locked --release' + +# Directory for Rust artifacts +ENV RELEASE_DIR=/src/target/x86_64-unknown-linux-musl/release + +# Version injected at build time via --build-arg (set by make VERSION=...) +ARG VERSION +ENV VERSION=$VERSION + +# Load Rust sources +ADD src /src +WORKDIR /src/ + +# pre-fetch all workspace deps; we need them to build with `--network=none` later +RUN cargo fetch + +WORKDIR /src/parser/grpc-server +RUN --network=none <<-EOF + set -eu + cargo build ${CARGOFLAGS} + mkdir -p /rootfs + mv ${RELEASE_DIR}/parser_grpc_server /rootfs/ +EOF + +# Use busybox as a base so we can easily cp the pivot binary if needed +FROM stagex/core-busybox:1.36.1@sha256:cac5d773db1c69b832d022c469ccf5f52daf223b91166e6866d42d6983a3b374 AS package +COPY --from=build /rootfs/. . diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 00000000..f364f11f --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,24 @@ +{ + "name": "visualsign-parser-scripts", + "private": true, + "type": "module", + "description": "Demo TS clients for visualsign-parser gateway flows (x402 over Solana devnet, etc.).", + "scripts": { + "demo:x402-solana-devnet": "tsx x402-solana-devnet-demo.ts" + }, + "engines": { + "node": ">=20.0.0" + }, + "dependencies": { + "@noble/curves": "^1.6.0", + "@solana/spl-token": "^0.4.8", + "@solana/web3.js": "^1.95.5" + }, + "optionalDependencies": { + "x402-solana": "^2.0.1" + }, + "devDependencies": { + "tsx": "^4.20.0", + "typescript": "^5.6.3" + } +} diff --git a/scripts/x402-solana-devnet-demo.ts b/scripts/x402-solana-devnet-demo.ts new file mode 100644 index 00000000..ebd75c79 --- /dev/null +++ b/scripts/x402-solana-devnet-demo.ts @@ -0,0 +1,280 @@ +#!/usr/bin/env -S npx tsx +/** + * x402 demo client against the visualsign-parser gateway with payai facilitator + * on Solana devnet. + * + * Drives the same flow as the gated Rust integration test + * (`tests/x402_payai_devnet_test.rs`) but lives outside the cargo test loop so + * humans can poke at it without rebuilding the workspace. + * + * Why this exists in TypeScript: + * payai's `x402-solana` npm package is the **supported** Solana x402 client. + * The repo's Rust client in `src/integration/src/solana_x402_client.rs` is + * test-only, and we deliberately do not ship a production Rust client. + * + * Prerequisites: + * 1. The gateway is running locally with X402_PROFILE=payai and + * X402_NETWORK=solana-devnet. Easiest: `make dev-up-payai` from the repo + * root, which brings up parser_grpc_server + parser_gateway as stagex + * images via compose.payai.yml. + * 2. The reproducible test wallet from `src/integration/fixtures/devnet/` + * is funded with devnet SOL (faucet.solana.com) and devnet USDC + * (faucet.circle.com, mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU). + * Current address (from wallet.seed): + * x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW + * + * Env vars (all optional): + * GATEWAY_URL default http://127.0.0.1:8080 + * RPC_URL default https://api.devnet.solana.com + * WALLET_SEED default ../src/integration/fixtures/devnet/wallet.seed + * X402_TVC_VERIFIER_PUBKEY_HEX + * if set, the demo independently P256-verifies the response + * signature against this key (cross-impl check vs the gateway). + */ + +import { Connection, Keypair, PublicKey, VersionedTransaction } from "@solana/web3.js"; +import { fileURLToPath } from "node:url"; +import { readFile } from "node:fs/promises"; +import { p256 } from "@noble/curves/p256"; +import { dirname, resolve } from "node:path"; + +// PayAI's vetted Solana x402 client (TS only). If the package is not installed +// the script will fall back to a hand-built X-PAYMENT header. +let createPaymentHeader: ((opts: unknown) => Promise) | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = await import("x402-solana"); + // The exact export name has drifted across payai SDK versions; pick whichever + // is present. + createPaymentHeader = + (mod as Record).createPaymentHeader as + | ((opts: unknown) => Promise) + | null + ?? null; +} catch { + createPaymentHeader = null; +} + +const GATEWAY_URL = process.env.GATEWAY_URL ?? "http://127.0.0.1:8080"; +const RPC_URL = process.env.RPC_URL ?? "https://api.devnet.solana.com"; +const TVC_HEX = process.env.X402_TVC_VERIFIER_PUBKEY_HEX?.toLowerCase(); + +async function loadBuyerKeypair(): Promise { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const defaultSeedPath = resolve( + __dirname, + "../src/integration/fixtures/devnet/wallet.seed", + ); + const seedPath = process.env.WALLET_SEED ?? defaultSeedPath; + const raw = await readFile(seedPath, "utf-8"); + const seed = Buffer.from(raw.trim(), "utf-8"); + if (seed.length !== 32) { + throw new Error(`wallet.seed must be 32 bytes; got ${seed.length}`); + } + return Keypair.fromSeed(seed); +} + +function logSection(title: string): void { + console.log(""); + console.log(`── ${title} `.padEnd(72, "─")); +} + +async function main(): Promise { + const buyer = await loadBuyerKeypair(); + logSection("Wallet"); + console.log("buyer address :", buyer.publicKey.toBase58()); + const conn = new Connection(RPC_URL, "confirmed"); + const lamports = await conn.getBalance(buyer.publicKey, "confirmed"); + console.log("buyer balance :", (lamports / 1e9).toFixed(4), "SOL on devnet"); + if (lamports < 50_000_000) { + console.warn( + `WARNING: low SOL balance (${lamports} lamports). Run \`solana airdrop 2 ${buyer.publicKey.toBase58()} --url devnet\` or visit https://faucet.solana.com`, + ); + } + + logSection("Probe 402"); + const probeBody = { + request: { unsigned_payload: "0xdeadbeef", chain: "CHAIN_ETHEREUM" }, + }; + const probe = await fetch(`${GATEWAY_URL}/visualsign/api/v2/parse`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(probeBody), + }); + if (probe.status !== 402) { + throw new Error(`expected 402 from gateway, got ${probe.status}`); + } + const paymentRequiredB64 = probe.headers.get("Payment-Required"); + if (!paymentRequiredB64) { + throw new Error("missing Payment-Required header on 402"); + } + const paymentRequired = JSON.parse( + Buffer.from(paymentRequiredB64, "base64").toString("utf-8"), + ) as { + x402Version: number; + accepts: Array<{ + scheme: string; + network: string; + amount: string; + asset: string; + payTo: string; + extra?: Record; + }>; + }; + console.log("accepts:", paymentRequired.accepts.map((a) => a.network).join(", ")); + const challenge = paymentRequired.accepts.find( + (a) => a.network === "solana-devnet", + ); + if (!challenge) { + throw new Error( + "gateway did not advertise solana-devnet in 402 accepts; check X402_NETWORK env", + ); + } + console.log( + `price: ${challenge.amount} atoms USDC -> ${challenge.payTo} on ${challenge.network}`, + ); + + logSection("Sign X-PAYMENT"); + let xPaymentHeader: string; + if (createPaymentHeader) { + console.log("(using payai x402-solana client)"); + xPaymentHeader = await createPaymentHeader({ + requirements: challenge, + buyer, + rpcUrl: RPC_URL, + }); + } else { + console.log("(payai x402-solana not installed — building header inline)"); + xPaymentHeader = await buildXPaymentInline(conn, buyer, challenge); + } + console.log("header length:", xPaymentHeader.length, "chars"); + + logSection("Paid request"); + const ethTx = + "0xf86c808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"; + const paid = await fetch(`${GATEWAY_URL}/visualsign/api/v2/parse`, { + method: "POST", + headers: { + "content-type": "application/json", + "X-PAYMENT": xPaymentHeader, + }, + body: JSON.stringify({ + request: { unsigned_payload: ethTx, chain: "CHAIN_ETHEREUM" }, + }), + }); + console.log("status:", paid.status); + const xpr = paid.headers.get("X-PAYMENT-RESPONSE"); + if (xpr) { + console.log("X-PAYMENT-RESPONSE:", xpr.slice(0, 80), xpr.length > 80 ? "…" : ""); + } + const text = await paid.text(); + if (paid.status !== 200) { + throw new Error(`paid request failed: ${paid.status} ${text}`); + } + const body = JSON.parse(text) as { + response: { + parsedTransaction: { + signature: { + publicKey: string; + message: string; + signature: string; + scheme: string; + }; + payload: { signablePayload: string }; + }; + }; + }; + const sig = body.response.parsedTransaction.signature; + console.log("signature pubkey:", sig.publicKey.slice(0, 24), "…"); + + if (TVC_HEX) { + logSection("Independent P256 verification"); + if (sig.publicKey.toLowerCase() !== TVC_HEX) { + throw new Error( + `response pubkey ${sig.publicKey} != X402_TVC_VERIFIER_PUBKEY_HEX`, + ); + } + // qos_p256 encodes P256Public as encrypt_public || sign_public, each SEC1 + // uncompressed (65 bytes). The signature is over the message bytes; the + // sign half is the second 65 bytes. @noble/curves verifies prehashed + // messages via the .verify path; both signer and verifier hash again. + const pubBytes = Buffer.from(sig.publicKey, "hex"); + if (pubBytes.length !== 130) { + throw new Error( + `expected 130-byte P256Public, got ${pubBytes.length}; aborting cross-check`, + ); + } + const signHalf = pubBytes.subarray(65, 130); + const msgBytes = Buffer.from(sig.message, "hex"); + const sigBytes = Buffer.from(sig.signature, "hex"); + const ok = p256.verify(sigBytes, msgBytes, signHalf, { prehash: false }); + if (!ok) { + throw new Error("independent P256 verification FAILED"); + } + console.log("response signature verifies against pinned TVC pubkey ✓"); + } + + logSection("Done"); + console.log("payload bytes:", body.response.parsedTransaction.payload.signablePayload.length); +} + +async function buildXPaymentInline( + conn: Connection, + buyer: Keypair, + challenge: { + scheme: string; + network: string; + amount: string; + asset: string; + payTo: string; + extra?: Record; + }, +): Promise { + // Fallback hand-built X-PAYMENT. Mirrors what payai's x402-solana would + // produce: an SPL Token v1 transfer from buyer ATA to receiver ATA, signed + // by the buyer only; the facilitator fills the fee-payer slot at /settle. + const { TOKEN_PROGRAM_ID, createTransferInstruction, getAssociatedTokenAddress } = + await import("@solana/spl-token"); + const { TransactionMessage } = await import("@solana/web3.js"); + + const mint = new PublicKey(challenge.asset); + const receiver = new PublicKey(challenge.payTo); + const buyerAta = await getAssociatedTokenAddress(mint, buyer.publicKey); + const receiverAta = await getAssociatedTokenAddress(mint, receiver); + const amount = BigInt(challenge.amount); + + const ix = createTransferInstruction( + buyerAta, + receiverAta, + buyer.publicKey, + amount, + [], + TOKEN_PROGRAM_ID, + ); + + const blockhash = (await conn.getLatestBlockhash("confirmed")).blockhash; + const message = new TransactionMessage({ + payerKey: buyer.publicKey, + recentBlockhash: blockhash, + instructions: [ix], + }).compileToLegacyMessage(); + + const tx = new VersionedTransaction(message); + tx.sign([buyer]); + const txB64 = Buffer.from(tx.serialize()).toString("base64"); + + const payload = { + x402Version: 2, + scheme: challenge.scheme, + network: challenge.network, + payload: { transaction: txB64 }, + accepted: challenge, + }; + return Buffer.from(JSON.stringify(payload)).toString("base64"); +} + +main().catch((e) => { + console.error("FATAL:", e); + process.exitCode = 1; +}); diff --git a/src/Cargo.lock b/src/Cargo.lock index 8555655b..3017bc2e 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -4768,6 +4768,7 @@ name = "integration" version = "0.1.0" dependencies = [ "base64 0.22.1", + "bincode", "borsh 1.6.0", "bs58 0.5.1", "chrono", @@ -4792,6 +4793,11 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "solana-client", + "solana-sdk", + "spl-associated-token-account 6.0.0", + "spl-token 7.0.0", + "thiserror 1.0.69", "tokio", "tonic 0.9.2", "tracing", @@ -6653,14 +6659,19 @@ version = "0.1.0" dependencies = [ "alloy-primitives", "axum 0.8.8", + "borsh 1.6.0", "generated", "health_check", "host_primitives", + "qos_crypto", + "qos_hex", + "qos_p256", "reqwest 0.13.3", "rust_decimal", "serde", "serde_json", "solana-pubkey 2.4.0", + "subtle", "thiserror 1.0.69", "tokio", "tracing", diff --git a/src/integration/Cargo.toml b/src/integration/Cargo.toml index 8aa3cac2..952ae8b2 100644 --- a/src/integration/Cargo.toml +++ b/src/integration/Cargo.toml @@ -56,5 +56,13 @@ tracing = { workspace = true } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "net", "time"] } +# Test-only Solana x402 client (no production crate depends on these). +solana-sdk = "2.1.15" +solana-client = "2.1.15" +spl-token = "7.0.0" +spl-associated-token-account = "6.0.0" +bincode = "1.3" +thiserror = "1" + [lints] workspace = true diff --git a/src/integration/fixtures/devnet/README.md b/src/integration/fixtures/devnet/README.md new file mode 100644 index 00000000..04edf308 --- /dev/null +++ b/src/integration/fixtures/devnet/README.md @@ -0,0 +1,32 @@ +# Devnet x402 test fixtures + +**NON-SECRET.** These files seed a Solana devnet wallet used by +`x402_payai_devnet_test.rs` and `scripts/x402-solana-devnet-demo.ts`. Devnet +funds only; no production wallet ever derives from this seed. + +## Files + +- `wallet.seed`: 32-byte ASCII seed for the buyer wallet. Both Rust and TS + paths derive a Solana keypair via `Keypair::from_seed(read("wallet.seed"))`. + Trailing whitespace/newlines are trimmed before use. +- `wallet.address`: cached base58 buyer pubkey (the address you fund). Kept + for documentation / quick-copy. The current value: + `x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW`. + +The devnet test self-transfers USDC (buyer == receiver), so no separate +`receiver.pub` is required — the buyer wallet is on both sides. If you need +a distinct receiver, set `X402_PAYTO` in the gateway env before launch. + +## Why this seed is committed + +The wallet is a long-lived devnet fixture funded with SOL + USDC. Rotating +the seed each run would invalidate the funded balance and force every +contributor (and CI) to re-airdrop before each test. The seed never controls +real assets. + +## Refilling + +- Devnet SOL: `solana airdrop 2
--url devnet` or + +- Devnet USDC: — mint + `4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU`, 6 decimals diff --git a/src/integration/fixtures/devnet/wallet.address b/src/integration/fixtures/devnet/wallet.address new file mode 100644 index 00000000..f4f626c2 --- /dev/null +++ b/src/integration/fixtures/devnet/wallet.address @@ -0,0 +1 @@ +x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW diff --git a/src/integration/fixtures/devnet/wallet.seed b/src/integration/fixtures/devnet/wallet.seed new file mode 100644 index 00000000..1850278d --- /dev/null +++ b/src/integration/fixtures/devnet/wallet.seed @@ -0,0 +1 @@ +visualsign-x402-devnet-test-seed \ No newline at end of file diff --git a/src/integration/src/solana_x402_client.rs b/src/integration/src/solana_x402_client.rs new file mode 100644 index 00000000..00a00730 --- /dev/null +++ b/src/integration/src/solana_x402_client.rs @@ -0,0 +1,253 @@ +//! Minimal test-only x402 Solana client. +//! +//! Builds an `X-PAYMENT` header for the v2 `exact` scheme on Solana, given a +//! `Payment-Required` challenge from a gated endpoint. The client signs a +//! `VersionedTransaction` that transfers USDC from the buyer's ATA to the +//! seller's ATA, leaves the fee-payer slot empty for the facilitator to fill +//! at `/settle`, and packages the partially-signed transaction in the wire +//! format described in +//! `coinbase/x402/specs/schemes/exact/scheme_exact_svm.md`. +//! +//! This client only ships in the integration test crate. We deliberately do +//! NOT publish a production Rust x402 Solana client — payai's `x402-solana` +//! npm package is the supported reference implementation; this Rust version +//! exists solely so `cargo test` can exercise the gateway's wire format end +//! to end on devnet without a Node dependency. + +use base64::Engine; +use serde::{Deserialize, Serialize}; +use solana_client::rpc_client::RpcClient; +use solana_sdk::commitment_config::CommitmentConfig; +use solana_sdk::hash::Hash; +use solana_sdk::message::{Message, VersionedMessage}; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::signature::{Keypair, SeedDerivable, Signer}; +use solana_sdk::transaction::VersionedTransaction; +use spl_associated_token_account::get_associated_token_address; +use std::str::FromStr; + +/// Subset of the `PaymentRequirements` challenge body the gateway emits in +/// the `Payment-Required` header. We only need the fields below. +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] // some fields ride along for visibility / future use +pub struct PaymentRequirementsLite { + pub scheme: String, + pub network: String, + pub amount: String, + pub asset: String, + #[serde(rename = "payTo")] + pub pay_to: String, + #[serde(default)] + pub extra: Option, + /// Full original JSON so we can echo it back in the `accepted` field of + /// the payment payload exactly as the server offered it. + #[serde(skip)] + pub raw: serde_json::Value, +} + +impl PaymentRequirementsLite { + pub fn from_value(v: &serde_json::Value) -> Result { + let mut parsed: Self = serde_json::from_value(v.clone()) + .map_err(|e| ClientError::BadChallenge(format!("parse PaymentRequirements: {e}")))?; + parsed.raw = v.clone(); + if parsed.scheme != "exact" { + return Err(ClientError::BadChallenge(format!( + "unsupported scheme '{}', only 'exact' is supported", + parsed.scheme + ))); + } + Ok(parsed) + } + + /// Devnet vs mainnet routing for the buyer's RPC. The challenge advertises + /// `solana-devnet` (v1) or `solana:EtWTRAB…` (CAIP-2) — both map to + /// devnet. + pub fn is_devnet(&self) -> bool { + self.network == "solana-devnet" || self.network.starts_with("solana:EtWTRAB") + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ClientError { + #[error("malformed challenge: {0}")] + BadChallenge(String), + #[error("bad pubkey: {0}")] + BadPubkey(String), + #[error("rpc error: {0}")] + Rpc(String), + #[error("serialization error: {0}")] + Serialize(String), + #[error("amount parse error: {0}")] + Amount(String), +} + +/// Outer wire shape for the `X-PAYMENT` header, per x402 v2 SVM exact. +#[derive(Debug, Serialize)] +struct PaymentPayload<'a> { + #[serde(rename = "x402Version")] + x402_version: u32, + scheme: &'a str, + network: &'a str, + payload: SvmPayload, + /// We echo the original challenge here so the server can compare what we + /// agreed to with what it offered. Some facilitators ignore it. + accepted: &'a serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct SvmPayload { + /// Base64 of the buyer-partially-signed `VersionedTransaction` bytes. + transaction: String, +} + +/// Build a buyer-signed `VersionedTransaction` carrying an SPL token transfer +/// from the buyer's ATA to the seller's ATA, with the fee-payer left for the +/// facilitator to fill in at `/settle`. +pub fn build_payment_transaction( + rpc: &RpcClient, + buyer: &Keypair, + requirements: &PaymentRequirementsLite, +) -> Result { + let usdc_mint = + Pubkey::from_str(&requirements.asset).map_err(|e| ClientError::BadPubkey(e.to_string()))?; + let seller = Pubkey::from_str(&requirements.pay_to) + .map_err(|e| ClientError::BadPubkey(e.to_string()))?; + let amount: u64 = requirements + .amount + .parse() + .map_err(|e| ClientError::Amount(format!("{e}")))?; + + let buyer_pk = buyer.pubkey(); + let buyer_ata = get_associated_token_address(&buyer_pk, &usdc_mint); + let seller_ata = get_associated_token_address(&seller, &usdc_mint); + + let mut instructions = vec![]; + + // Best-effort: if the seller's ATA doesn't exist, include a create-idempotent + // instruction up front. The facilitator (as fee payer) pays the rent. + instructions.push( + spl_associated_token_account::instruction::create_associated_token_account_idempotent( + &Pubkey::default(), // placeholder: facilitator replaces fee-payer + &seller, + &usdc_mint, + &spl_token::id(), + ), + ); + + // SPL Token v1 transfer instruction. v2 (Token-2022) requires + // `transfer_checked` and additional account metas; we keep v1 since payai + // accepts both via the scheme_exact_svm spec. + instructions.push( + spl_token::instruction::transfer( + &spl_token::id(), + &buyer_ata, + &seller_ata, + &buyer_pk, + &[&buyer_pk], + amount, + ) + .map_err(|e| ClientError::Serialize(format!("transfer ix: {e}")))?, + ); + + // Use the buyer as a temporary fee-payer placeholder. The wire format keeps + // the buyer's signature slot at index 0 and leaves index N for the + // facilitator; payai re-anchors fee-payer at /settle. Reading the spec + // (`scheme_exact_svm.md`) more closely is required if we ever sign for a + // strict fee-payer-as-feePayer position — for tests against payai this + // shape works because payai replaces the message header's + // num_required_signatures = 1 slot. + let recent_blockhash: Hash = rpc + .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed()) + .map_err(|e| ClientError::Rpc(e.to_string()))? + .0; + + let message = Message::new_with_blockhash(&instructions, Some(&buyer_pk), &recent_blockhash); + let versioned = VersionedMessage::Legacy(message); + let tx = VersionedTransaction::try_new(versioned, &[buyer]) + .map_err(|e| ClientError::Serialize(format!("sign tx: {e}")))?; + + Ok(tx) +} + +/// Serialize the buyer-signed transaction and wrap it in the v2 `X-PAYMENT` +/// header value. +pub fn build_x_payment_header( + challenge: &PaymentRequirementsLite, + tx: &VersionedTransaction, +) -> Result { + let tx_bytes = bincode::serialize(tx).map_err(|e| ClientError::Serialize(e.to_string()))?; + let tx_b64 = base64::engine::general_purpose::STANDARD.encode(&tx_bytes); + + let payload = PaymentPayload { + x402_version: 2, + scheme: &challenge.scheme, + network: &challenge.network, + payload: SvmPayload { + transaction: tx_b64, + }, + accepted: &challenge.raw, + }; + let json = + serde_json::to_string(&payload).map_err(|e| ClientError::Serialize(e.to_string()))?; + Ok(base64::engine::general_purpose::STANDARD.encode(json)) +} + +/// Load the reproducible devnet keypair from `fixtures/devnet/wallet.seed`. +/// Trims surrounding whitespace; expects exactly 32 bytes after trimming. +pub fn load_devnet_keypair(path: &str) -> Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| ClientError::BadChallenge(format!("read {path}: {e}")))?; + let trimmed = raw.trim().as_bytes(); + if trimmed.len() != 32 { + return Err(ClientError::BadChallenge(format!( + "expected 32-byte seed in {path}, got {} bytes", + trimmed.len() + ))); + } + Keypair::from_seed(trimmed) + .map_err(|e| ClientError::BadChallenge(format!("derive keypair: {e}"))) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn requirements_parses_devnet_challenge() { + let v = serde_json::json!({ + "scheme": "exact", + "network": "solana-devnet", + "amount": "1000", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "payTo": "EGBQqKn968sVv5cQh5Cr72pSTHfxsuzq7o7asqYB5uEV", + "extra": { "feePayer": "PayAiFacilitator11111111111111111111111111" } + }); + let r = PaymentRequirementsLite::from_value(&v).unwrap(); + assert!(r.is_devnet()); + assert_eq!(r.scheme, "exact"); + assert_eq!(r.amount, "1000"); + } + + #[test] + fn requirements_rejects_unsupported_scheme() { + let v = serde_json::json!({ + "scheme": "upto", + "network": "solana-devnet", + "amount": "1000", + "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + "payTo": "EGBQqKn968sVv5cQh5Cr72pSTHfxsuzq7o7asqYB5uEV" + }); + assert!(PaymentRequirementsLite::from_value(&v).is_err()); + } + + #[test] + fn load_devnet_keypair_round_trips() { + let kp = load_devnet_keypair("fixtures/devnet/wallet.seed").expect("load fixture keypair"); + // The address is deterministic. Print it so the test logs document + // the funded fixture address. Format-check only. + let addr = kp.pubkey().to_string(); + assert!(!addr.is_empty()); + eprintln!("[fixture] devnet buyer address: {addr}"); + } +} diff --git a/src/integration/tests/x402_gateway_test.rs b/src/integration/tests/x402_gateway_test.rs index aa265c44..4e6b2b49 100644 --- a/src/integration/tests/x402_gateway_test.rs +++ b/src/integration/tests/x402_gateway_test.rs @@ -3,6 +3,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use qos_p256::P256Pair; use std::net::TcpListener; use std::process::{Child, Command, Stdio}; use std::time::Duration; @@ -72,7 +73,20 @@ async fn wait_ready(url: &str) { panic!("service at {url} never became ready (timed out after 10 s)"); } +/// Load the test ephemeral key and return its `qos_hex` pubkey — the exact +/// format parser_app emits in the wire signature and the gateway pins via +/// `X402_TVC_VERIFIER_PUBKEY_HEX`. +fn fixture_ephemeral_pubkey_hex() -> String { + let pair = P256Pair::from_hex_file("fixtures/ephemeral.secret") + .expect("load fixtures/ephemeral.secret"); + qos_hex::encode(&pair.public_key().to_bytes()) +} + async fn start_procs() -> Procs { + start_procs_with_env(&[]).await +} + +async fn start_procs_with_env(extra: &[(&str, &str)]) -> Procs { // --- Friction 2: startup ordering --- // parser_gateway probes mock_facilitator at startup. We must ensure // mock_facilitator is ready before spawning the gateway. @@ -114,15 +128,19 @@ async fn start_procs() -> Procs { // 3. Start the gateway last — it probes the mock at startup. // Friction 5: env var names confirmed from gateway/src/main.rs. - let gateway = Command::new(target_bin("parser_gateway")) - .env("GATEWAY_PORT", GW_PORT.to_string()) + let mut cmd = Command::new(target_bin("parser_gateway")); + cmd.env("GATEWAY_PORT", GW_PORT.to_string()) // grpc server always listens on 44020 (hardcoded in binary) .env("GRPC_ADDR", "http://127.0.0.1:44020") .env("X402_PROFILE", "local") .env( "X402_FACILITATOR_URL", format!("http://127.0.0.1:{MOCK_PORT}"), - ) + ); + for (k, v) in extra { + cmd.env(k, v); + } + let gateway = cmd .stdout(Stdio::null()) .stderr(Stdio::inherit()) .spawn() @@ -382,3 +400,70 @@ async fn path5_health_open() { assert_eq!(resp.status(), 200); } + +/// Path 6: TVC attestation mismatch → 502 and no settlement. +/// +/// Pin a *non-matching* TVC pubkey on the gateway, then submit a valid payment +/// for a parseable transaction. parser_app produces a legitimate signature with +/// the fixture ephemeral key, but the gateway's pinned pubkey is a freshly +/// generated unrelated keypair, so the verifier rejects on pubkey mismatch. +/// The handler must return 502, and `/debug/settle_count` on the mock +/// facilitator must remain unchanged — the gateway must not have paid the +/// facilitator for an unattested response. +#[tokio::test] +async fn path6_tampered_pubkey_returns_502_no_settle() { + let _guard = TEST_MUTEX.lock().await; + + // Generate an unrelated keypair: the gateway will pin THIS pubkey, but + // parser_app will keep signing with the on-disk fixture key. The two won't + // match, so verification must fail. + let wrong = P256Pair::generate().expect("generate wrong keypair"); + let wrong_hex = qos_hex::encode(&wrong.public_key().to_bytes()); + // Sanity: must differ from the fixture's pubkey. + assert_ne!(wrong_hex, fixture_ephemeral_pubkey_hex()); + + let _p = start_procs_with_env(&[("X402_TVC_VERIFIER_PUBKEY_HEX", wrong_hex.as_str())]).await; + + // Read settle_count before the request. + let before = read_settle_count().await; + + let requirements = fetch_v2_requirements().await; + let payment_header = build_payment_signature(&requirements); + + let body = serde_json::json!({ + "request": { "unsigned_payload": ETH_TX_HEX, "chain": "CHAIN_ETHEREUM" } + }); + + let resp = reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{GW_PORT}/visualsign/api/v2/parse" + )) + .header("Payment-Signature", payment_header) + .json(&body) + .send() + .await + .unwrap(); + + let status = resp.status(); + let body_text = resp.text().await.unwrap_or_default(); + assert_eq!( + status, 502, + "expected 502 Bad Gateway on attestation mismatch; got {status}; body: {body_text}" + ); + + // Settlement must NOT have happened — payment must not be charged for an + // unattested response. + let after = read_settle_count().await; + assert_eq!( + before, after, + "/debug/settle_count must not advance for an attestation failure" + ); +} + +async fn read_settle_count() -> usize { + let resp = reqwest::get(format!("http://127.0.0.1:{MOCK_PORT}/debug/settle_count")) + .await + .expect("read settle_count"); + let v: serde_json::Value = resp.json().await.expect("settle_count JSON"); + v["settle_count"].as_u64().expect("settle_count number") as usize +} diff --git a/src/integration/tests/x402_payai_devnet_test.rs b/src/integration/tests/x402_payai_devnet_test.rs new file mode 100644 index 00000000..b1bf5863 --- /dev/null +++ b/src/integration/tests/x402_payai_devnet_test.rs @@ -0,0 +1,313 @@ +//! End-to-end x402 gating against the **real** payai facilitator on Solana +//! **devnet**. Gated by `#[ignore]` AND `X402_E2E=1` so it stays out of the +//! default `cargo test` run. +//! +//! Run with: +//! ```sh +//! X402_E2E=1 cargo test -p integration --test x402_payai_devnet_test -- --ignored --nocapture +//! ``` +//! +//! Requirements: +//! - Network egress to `https://facilitator.payai.network` + Solana devnet RPC. +//! - The committed fixture wallet (derived from +//! `src/integration/fixtures/devnet/wallet.seed`) must be funded with at +//! least `MIN_DEVNET_SOL` SOL and `MIN_DEVNET_USDC` USDC on devnet. Faucets: +//! and . +//! - The gateway is built locally (`make -C src build` first). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +#[path = "../src/solana_x402_client.rs"] +mod solana_x402_client; + +use base64::Engine; +use qos_p256::P256Pair; +use solana_client::rpc_client::RpcClient; +use solana_sdk::commitment_config::CommitmentConfig; +use solana_sdk::signer::Signer; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::time::sleep; + +use solana_x402_client::{ + PaymentRequirementsLite, build_payment_transaction, build_x_payment_header, load_devnet_keypair, +}; + +// Lock with the existing x402_gateway_test paths — they share ports 18080 / +// 18090 / 44020 and both touch the parser_grpc_server. +static TEST_MUTEX: Mutex<()> = Mutex::const_new(()); + +const GW_PORT: u16 = 18180; +const PAYAI_FACILITATOR: &str = "https://facilitator.payai.network"; +const DEVNET_RPC: &str = "https://api.devnet.solana.com"; +const MIN_DEVNET_SOL_LAMPORTS: u64 = 50_000_000; // 0.05 SOL +const MIN_DEVNET_USDC_ATOMIC: u64 = 1_000_000; // 1.00 USDC +const PARSER_PRICE_USD: &str = "0.001"; // matches X402_NETWORK=solana-devnet default +const WALLET_SEED_PATH: &str = "fixtures/devnet/wallet.seed"; + +fn target_bin(name: &str) -> String { + format!("../target/debug/{name}") +} + +struct Procs { + grpc: Child, + gateway: Child, +} + +impl Drop for Procs { + fn drop(&mut self) { + let _ = self.grpc.kill(); + let _ = self.gateway.kill(); + let _ = self.grpc.wait(); + let _ = self.gateway.wait(); + } +} + +async fn wait_until_port_free(port: u16) { + for _ in 0..100 { + if TcpListener::bind(("127.0.0.1", port)).is_ok() { + return; + } + sleep(Duration::from_millis(50)).await; + } +} + +async fn wait_ready(url: &str) { + let client = reqwest::Client::new(); + for _ in 0..200 { + if let Ok(r) = client.get(url).send().await { + if r.status().is_success() { + return; + } + } + sleep(Duration::from_millis(100)).await; + } + panic!("service at {url} never became ready (timed out after 20 s)"); +} + +async fn start_stack(receiver_b58: &str, tvc_pubkey_hex: &str) -> Procs { + wait_until_port_free(44020).await; + wait_until_port_free(GW_PORT).await; + + let grpc = Command::new(target_bin("parser_grpc_server")) + .env("EPHEMERAL_FILE", "fixtures/ephemeral.secret") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn parser_grpc_server"); + + let gateway = Command::new(target_bin("parser_gateway")) + .env("GATEWAY_PORT", GW_PORT.to_string()) + .env("GRPC_ADDR", "http://127.0.0.1:44020") + .env("X402_PROFILE", "payai") + .env("X402_FACILITATOR_URL", PAYAI_FACILITATOR) + .env("X402_NETWORK", "solana-devnet") + .env("X402_PAYTO", receiver_b58) + .env("X402_TVC_VERIFIER_PUBKEY_HEX", tvc_pubkey_hex) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn parser_gateway"); + + wait_ready(&format!("http://127.0.0.1:{GW_PORT}/health")).await; + Procs { grpc, gateway } +} + +fn fixture_ephemeral_pubkey_hex() -> String { + let pair = P256Pair::from_hex_file("fixtures/ephemeral.secret") + .expect("load fixtures/ephemeral.secret"); + qos_hex::encode(&pair.public_key().to_bytes()) +} + +fn skip_unless_e2e() -> bool { + if std::env::var("X402_E2E").as_deref().unwrap_or("") != "1" { + eprintln!("skip: X402_E2E=1 not set (see test module docs for prerequisites)"); + return true; + } + false +} + +fn assert_wallet_funded(rpc: &RpcClient, buyer_pk: &solana_sdk::pubkey::Pubkey) { + let sol_lamports = rpc + .get_balance_with_commitment(buyer_pk, CommitmentConfig::confirmed()) + .expect("query SOL balance") + .value; + if sol_lamports < MIN_DEVNET_SOL_LAMPORTS { + // Best-effort airdrop. Faucet often rate-limits; we panic with + // instructions if it doesn't grant enough. + let _ = rpc.request_airdrop(buyer_pk, MIN_DEVNET_SOL_LAMPORTS * 2); + std::thread::sleep(Duration::from_secs(8)); + let after = rpc + .get_balance_with_commitment(buyer_pk, CommitmentConfig::confirmed()) + .expect("re-query SOL balance") + .value; + if after < MIN_DEVNET_SOL_LAMPORTS { + panic!( + "buyer wallet {buyer_pk} has only {after} lamports on devnet; \ + fund it via https://faucet.solana.com or `solana airdrop` and retry" + ); + } + } + + // Circle devnet USDC. + let usdc_mint = + solana_sdk::pubkey::Pubkey::try_from("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU") + .expect("devnet USDC mint pubkey"); + let buyer_ata = + spl_associated_token_account::get_associated_token_address(buyer_pk, &usdc_mint); + match rpc.get_token_account_balance(&buyer_ata) { + Ok(bal) => { + let amount: u64 = bal.amount.parse().expect("token amount must parse as u64"); + if amount < MIN_DEVNET_USDC_ATOMIC { + panic!( + "buyer ATA {buyer_ata} has only {amount} USDC atoms on devnet \ + (need {MIN_DEVNET_USDC_ATOMIC}); fund via https://faucet.circle.com" + ); + } + } + Err(e) => { + panic!( + "buyer ATA {buyer_ata} for {buyer_pk} does not exist or is unreadable on \ + devnet: {e}; fund via https://faucet.circle.com" + ); + } + } +} + +async fn fetch_v2_challenge() -> serde_json::Value { + let body = serde_json::json!({ + "request": { "unsigned_payload": "0xdeadbeef", "chain": "CHAIN_ETHEREUM" } + }); + let resp = reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{GW_PORT}/visualsign/api/v2/parse" + )) + .json(&body) + .send() + .await + .expect("send probe"); + assert_eq!(resp.status(), 402, "expected 402 for probe"); + let header = resp + .headers() + .get("Payment-Required") + .expect("Payment-Required header") + .to_str() + .unwrap() + .to_string(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(header.as_bytes()) + .expect("base64 Payment-Required"); + let parsed: serde_json::Value = + serde_json::from_slice(&decoded).expect("Payment-Required JSON"); + parsed +} + +/// Path 7a: gateway boots with payai + solana-devnet and serves a 402 whose +/// challenge advertises `solana-devnet`. No payment is performed here; this +/// path is cheap and lets us validate the boot path without spending USDC. +#[tokio::test] +#[ignore] +async fn path7a_gateway_boots_with_payai_devnet() { + if skip_unless_e2e() { + return; + } + let _guard = TEST_MUTEX.lock().await; + + let buyer = load_devnet_keypair(WALLET_SEED_PATH).expect("load fixture keypair"); + let receiver = buyer.pubkey(); // self-receive is fine for this probe + let tvc_hex = fixture_ephemeral_pubkey_hex(); + + let _p = start_stack(&receiver.to_string(), &tvc_hex).await; + + let challenge = fetch_v2_challenge().await; + let accepts = challenge["accepts"].as_array().expect("accepts array"); + let has_devnet = accepts + .iter() + .any(|t| t["network"].as_str() == Some("solana-devnet")); + assert!( + has_devnet, + "expected solana-devnet in 402 accepts; got: {accepts:?}" + ); +} + +/// Path 7b: full pay → parse → verify cycle against real payai facilitator +/// and real Solana devnet USDC. Spends `PARSER_PRICE_USD` from the fixture +/// wallet. +#[tokio::test] +#[ignore] +async fn path7b_full_devnet_pay_and_verify() { + if skip_unless_e2e() { + return; + } + let _guard = TEST_MUTEX.lock().await; + + let buyer = load_devnet_keypair(WALLET_SEED_PATH).expect("load fixture keypair"); + let buyer_pk = buyer.pubkey(); + eprintln!("[fixture] devnet buyer address: {buyer_pk}"); + + let rpc = RpcClient::new_with_commitment(DEVNET_RPC.to_string(), CommitmentConfig::confirmed()); + assert_wallet_funded(&rpc, &buyer_pk); + + let receiver = buyer_pk; // self-transfer keeps test self-contained + let tvc_hex = fixture_ephemeral_pubkey_hex(); + let _p = start_stack(&receiver.to_string(), &tvc_hex).await; + + let challenge = fetch_v2_challenge().await; + let accepts = challenge["accepts"] + .as_array() + .expect("accepts must be array"); + let devnet = accepts + .iter() + .find(|t| t["network"].as_str() == Some("solana-devnet")) + .expect("solana-devnet entry in accepts") + .clone(); + + let reqs = PaymentRequirementsLite::from_value(&devnet).expect("parse devnet challenge"); + let tx = build_payment_transaction(&rpc, &buyer, &reqs).expect("build payment tx"); + let header_value = build_x_payment_header(&reqs, &tx).expect("build X-PAYMENT header"); + + let eth_tx_hex = "0xf86c808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"; + + let body = serde_json::json!({ + "request": { "unsigned_payload": eth_tx_hex, "chain": "CHAIN_ETHEREUM" } + }); + let resp = reqwest::Client::new() + .post(format!( + "http://127.0.0.1:{GW_PORT}/visualsign/api/v2/parse" + )) + .header("X-PAYMENT", header_value) + .json(&body) + .send() + .await + .expect("send paid request"); + let status = resp.status(); + let x_payment_response = resp + .headers() + .get("X-PAYMENT-RESPONSE") + .map(|v| v.to_str().unwrap_or_default().to_string()); + let body_text = resp.text().await.unwrap_or_default(); + assert_eq!( + status, 200, + "expected 200 after paying; body: {body_text}; price ${PARSER_PRICE_USD}" + ); + assert!( + x_payment_response.is_some(), + "expected X-PAYMENT-RESPONSE header on 200" + ); + + // Cross-check: the gateway's own pinned verifier already passed. We also + // verify here in the test that the response's `signature.publicKey` equals + // the pinned hex, so this test would catch any regression in the gateway's + // attestation wiring. + let v: serde_json::Value = serde_json::from_str(&body_text).expect("response JSON"); + let response_pubkey = v["response"]["parsedTransaction"]["signature"]["publicKey"] + .as_str() + .expect("response.signature.publicKey must be present"); + assert_eq!( + response_pubkey.to_ascii_lowercase(), + tvc_hex.to_ascii_lowercase(), + "response pubkey must match pinned TVC verifier" + ); +} diff --git a/src/parser/gateway/Cargo.toml b/src/parser/gateway/Cargo.toml index 08e4036c..aa1a28eb 100644 --- a/src/parser/gateway/Cargo.toml +++ b/src/parser/gateway/Cargo.toml @@ -31,12 +31,21 @@ solana-pubkey = "2" rust_decimal = "1" url = "2" +# attestation: verifies the TVC ephemeral-key signature on the parse response +qos_p256 = { workspace = true } +qos_hex = { workspace = true } +subtle = { version = "2", default-features = false } + # error handling thiserror = "1" # http client (for startup facilitator probe, Task 9) reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } +[dev-dependencies] +qos_crypto = { workspace = true } +borsh = { version = "1", features = ["std", "derive"], default-features = false } + [lib] name = "parser_gateway" path = "src/lib.rs" diff --git a/src/parser/gateway/README.md b/src/parser/gateway/README.md new file mode 100644 index 00000000..2e1f1eb2 --- /dev/null +++ b/src/parser/gateway/README.md @@ -0,0 +1,132 @@ +# parser_gateway + +HTTP gateway in front of `parser_app`'s gRPC service. Terminates client +requests, optionally gates `/visualsign/api/v2/parse` behind an x402 +(HTTP 402 Payment Required) handshake, and verifies the TVC enclave's +signature on every parse response before returning it. + +## Routes + +| Method | Path | Gated by x402? | Notes | +| ------ | ----------------------------- | -------------- | ---------------------------------- | +| GET | `/health` | no | proxy to backend gRPC health | +| POST | `/visualsign/api/v1/parse` | no | legacy, open | +| POST | `/visualsign/api/v2/parse` | **yes** | configured via env (see below) | + +The v2 route is only mounted if the configured x402 facilitator responds +to a `/supported` probe at startup. If the facilitator is unreachable the +gateway logs and continues serving v1 + health only. + +## TVC attestation + +Every successful v2 (and v1) parse response is signed by `parser_app`'s +ephemeral P256 keypair, provisioned into the enclave at boot. The gateway +verifies the signature against a **pinned** public key. On failure it +returns `502 Bad Gateway`; the x402 middleware's settle-on-success +contract then skips `/settle`, so an unattested response is never +charged to the payer. + +The pinned pubkey is provided to the gateway as a launch argument by the +TVC stack. The value is `qos_hex::encode(P256Public::to_bytes())` — the +exact format `parser_app` emits in the wire signature's `publicKey` field. + +```sh +# Set by TVC at boot (or via your local-dev compose file) +X402_TVC_VERIFIER_PUBKEY_HEX=<260 hex chars> +# Or, equivalently: +X402_TVC_VERIFIER_PUBKEY_FILE=/path/to/pubkey.hex +``` + +If neither is set: +- `X402_PROFILE=local`: the gateway logs a warning and skips attestation. +- otherwise: the gateway **exits with code 1** at startup (fail-closed). + +## x402 configuration + +All env vars are read at startup. Bad values fail-closed (gateway exits 1). + +| Env var | Required? | Default | Meaning | +| -------------------------------- | --------- | ----------------------------------- | -------------------------------------------------------------------------------------- | +| `GATEWAY_PORT` | no | `8080` | bind port | +| `GRPC_ADDR` | no | `http://127.0.0.1:44020` | parser_app / parser_grpc_server endpoint | +| `X402_PROFILE` | no | `local` | one of `local`, `payai`, `custom` | +| `X402_FACILITATOR_URL` | depends | profile-default | overrides per-profile default | +| `X402_FACILITATOR_TIMEOUT_SECS` | no | `5` | facilitator HTTP timeout | +| `X402_NETWORK` | no | profile-default | `base-sepolia`, `base`, `solana`, `solana-devnet` | +| `X402_PAYTO` | depends | burn address for `local` | EVM `0x…` or Solana base58 | +| `X402_PRICE_TAGS_JSON` | no | seeded from profile + `X402_NETWORK` | full multi-tag override; see the JSON shape in `x402_config.rs` | +| `X402_TVC_VERIFIER_PUBKEY_HEX` | **yes** (non-local) | — | pinned enclave pubkey, hex | +| `X402_TVC_VERIFIER_PUBKEY_FILE` | no | — | alternative to `_HEX`: file holding the hex | + +### Profiles + +- `local` — `X402_FACILITATOR_URL` defaults to `http://127.0.0.1:8090` + (the bundled `mock_facilitator`). `X402_NETWORK` defaults to + `base-sepolia`. Designed for offline dev. +- `payai` — facilitator defaults to `https://facilitator.payai.network`. + `X402_NETWORK` defaults to `base`; set it to `solana-devnet` for the + devnet flow. +- `custom` — bring your own facilitator URL and price tags via env. + +### Network egress requirement + +The `payai` profile requires outbound HTTPS to +`facilitator.payai.network` from wherever the gateway runs. In TVC +deployments the gateway runs on the host VM (outside the enclave); the +enclave-host networking already provides egress for Turnkey integrations. + +## Local-dev stacks (containerized) + +Two thin `docker-compose` files at the repo root consume the same +stagex-built OCI images that ship to GHCR in production. Build them +first with `make non-oci-docker-images`. + +```sh +# Offline / fully self-contained — uses bundled mock_facilitator. +make dev-up-mock + +# Real payai facilitator on Solana devnet. +export X402_PAYTO= +export X402_TVC_VERIFIER_PUBKEY_HEX=<260-char hex from parser_app> +make dev-up-payai + +# Tear down either stack. +make dev-down +``` + +To target a TVC-deployed gateway image instead of a local build, edit +`compose.payai.yml` and replace +`image: anchorageoss-visualsign-parser/parser_gateway:latest` with the +GHCR digest from the release notes, e.g. +`image: ghcr.io/anchorageoss/parser_gateway:v0.1.2@sha256:`. + +## End-to-end demo (TypeScript) + +Drives the gated endpoint with a real x402 payment against payai + +Solana devnet: + +```sh +cd scripts +npm install +GATEWAY_URL=http://127.0.0.1:8080 \ +X402_TVC_VERIFIER_PUBKEY_HEX=<260-char hex> \ +npx tsx x402-solana-devnet-demo.ts +``` + +Uses the reproducible buyer wallet derived from +`src/integration/fixtures/devnet/wallet.seed`. The current address — +`x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW` — must be funded on +devnet with SOL + USDC before running. See +`src/integration/fixtures/devnet/README.md` for faucet links. + +## Integration tests + +```sh +# Always-on (offline, mock facilitator) — 6 paths including signature +# tamper detection. +make -C src test + +# Gated devnet E2E (real payai + Solana devnet). Requires the +# reproducible buyer wallet to be funded. +X402_E2E=1 cargo test -p integration --test x402_payai_devnet_test -- --ignored +``` diff --git a/src/parser/gateway/src/attestation.rs b/src/parser/gateway/src/attestation.rs new file mode 100644 index 00000000..940a079a --- /dev/null +++ b/src/parser/gateway/src/attestation.rs @@ -0,0 +1,286 @@ +//! Verifies that a parse response was signed by a pinned TVC (Turnkey Verifiable +//! Compute) ephemeral key. +//! +//! The gateway sits between an HTTP client (which may be paying via x402) and the +//! parser_app gRPC service. parser_app signs every response with an ephemeral +//! P256 keypair provisioned into the enclave by Turnkey. The gateway must refuse +//! to release the response to the client (and skip x402 settlement) unless the +//! signature verifies against a TVC pubkey that was pinned at the gateway's +//! launch time. The pubkey is provided via env var (`X402_TVC_VERIFIER_PUBKEY_HEX`, +//! or a file path via `X402_TVC_VERIFIER_PUBKEY_FILE`) and matches the same +//! `qos_hex::encode(P256Public::to_bytes())` format parser_app emits in the wire +//! signature. + +use generated::parser::{Signature, SignatureScheme}; +use qos_p256::P256Public; +use std::path::Path; +use subtle::ConstantTimeEq; + +#[derive(Debug, thiserror::Error)] +pub enum AttestationError { + #[error("missing signature on parse response")] + MissingSignature, + #[error("unsupported signature scheme: {0}")] + UnsupportedScheme(String), + #[error("public key mismatch: response key does not match pinned TVC verifier key")] + PubkeyMismatch, + #[error("hex decode error in {field}: {message}")] + Hex { + field: &'static str, + message: String, + }, + #[error("invalid pinned TVC public key: {0}")] + InvalidPinnedKey(String), + #[error("signature verification failed")] + Verify, + #[error("failed to read TVC pubkey file {path}: {message}")] + PubkeyFile { path: String, message: String }, +} + +pub struct AttestationVerifier { + pinned_public: P256Public, + pinned_hex_lower: String, +} + +impl AttestationVerifier { + /// Production entrypoint — reads from the real process environment. + /// + /// Returns `Ok(None)` if neither `X402_TVC_VERIFIER_PUBKEY_HEX` nor + /// `X402_TVC_VERIFIER_PUBKEY_FILE` is set. Callers decide whether absence + /// is fatal based on profile (production deployments fail closed; local + /// dev runs without a pinned verifier). + pub fn from_env() -> Result, AttestationError> { + Self::from_lookup(|key| std::env::var(key).ok()) + } + + /// Test-friendly core — takes a closure that resolves env-var lookups so + /// tests can inject values without mutating process state. + pub fn from_lookup(get: F) -> Result, AttestationError> + where + F: Fn(&str) -> Option, + { + let hex_value = match ( + get("X402_TVC_VERIFIER_PUBKEY_HEX"), + get("X402_TVC_VERIFIER_PUBKEY_FILE"), + ) { + (Some(s), _) => s, + (None, Some(path)) => std::fs::read_to_string(&path) + .map_err(|e| AttestationError::PubkeyFile { + path: path.clone(), + message: e.to_string(), + })? + .trim() + .to_string(), + (None, None) => return Ok(None), + }; + + Self::from_hex(&hex_value).map(Some) + } + + pub fn from_hex(hex_value: &str) -> Result { + let trimmed = hex_value.trim(); + let bytes = qos_hex::decode(trimmed).map_err(|e| AttestationError::Hex { + field: "X402_TVC_VERIFIER_PUBKEY_HEX", + message: format!("{e:?}"), + })?; + let pinned_public = P256Public::from_bytes(&bytes) + .map_err(|e| AttestationError::InvalidPinnedKey(format!("{e:?}")))?; + Ok(Self { + pinned_public, + pinned_hex_lower: trimmed.to_ascii_lowercase(), + }) + } + + /// Verify that the proto `Signature` on a parse response was produced by the + /// pinned TVC key. + pub fn verify(&self, sig: &Signature) -> Result<(), AttestationError> { + if sig.scheme != SignatureScheme::TurnkeyP256EphemeralKey as i32 { + let scheme_name = SignatureScheme::from_i32(sig.scheme) + .map(|s| s.as_str_name().to_string()) + .unwrap_or_else(|| format!("UNKNOWN({})", sig.scheme)); + return Err(AttestationError::UnsupportedScheme(scheme_name)); + } + + let response_hex_lower = sig.public_key.to_ascii_lowercase(); + let pinned_bytes = self.pinned_hex_lower.as_bytes(); + let response_bytes = response_hex_lower.as_bytes(); + if pinned_bytes.len() != response_bytes.len() + || pinned_bytes.ct_eq(response_bytes).unwrap_u8() != 1 + { + return Err(AttestationError::PubkeyMismatch); + } + + let digest = qos_hex::decode(&sig.message).map_err(|e| AttestationError::Hex { + field: "signature.message", + message: format!("{e:?}"), + })?; + let signature_bytes = + qos_hex::decode(&sig.signature).map_err(|e| AttestationError::Hex { + field: "signature.signature", + message: format!("{e:?}"), + })?; + + self.pinned_public + .verify(&digest, &signature_bytes) + .map_err(|_| AttestationError::Verify) + } + + /// Public hex representation of the pinned key, lowercased. Useful for + /// log/error messages. + pub fn pinned_hex(&self) -> &str { + &self.pinned_hex_lower + } +} + +/// Allow callers to fail closed when a pinned verifier is required but absent. +pub fn require_verifier( + profile_is_local: bool, + verifier: Option, +) -> Result, AttestationError> { + match (verifier, profile_is_local) { + (Some(v), _) => Ok(Some(v)), + (None, true) => Ok(None), + (None, false) => Err(AttestationError::InvalidPinnedKey( + "X402_TVC_VERIFIER_PUBKEY_HEX or _FILE required for non-local profile".into(), + )), + } +} + +/// Borrow the path-only helper into a non-allocating verifier of the file. +/// Provided for callers that prefer passing a `&Path` over going through env vars. +pub fn from_file(path: &Path) -> Result { + let raw = std::fs::read_to_string(path).map_err(|e| AttestationError::PubkeyFile { + path: path.display().to_string(), + message: e.to_string(), + })?; + AttestationVerifier::from_hex(raw.trim()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use generated::parser::{ParsedTransactionPayload, Signature, SignatureScheme}; + use qos_crypto::sha_256; + use qos_p256::P256Pair; + + fn make_signed_response(pair: &P256Pair) -> Signature { + let payload = ParsedTransactionPayload { + parsed_payload: "{}".to_string(), + input_payload_digest: String::new(), + metadata_digest: String::new(), + signable_payload: "{}".to_string(), + }; + let body = borsh::to_vec(&payload).unwrap(); + let digest = sha_256(&body); + let sig_bytes = pair.sign(&digest).unwrap(); + Signature { + public_key: qos_hex::encode(&pair.public_key().to_bytes()), + signature: qos_hex::encode(&sig_bytes), + message: qos_hex::encode(&digest), + scheme: SignatureScheme::TurnkeyP256EphemeralKey as i32, + } + } + + #[test] + fn from_lookup_absent_returns_none() { + let v = AttestationVerifier::from_lookup(|_| None).unwrap(); + assert!(v.is_none()); + } + + #[test] + fn round_trip_verifies_real_signature() { + let pair = P256Pair::generate().unwrap(); + let pinned_hex = qos_hex::encode(&pair.public_key().to_bytes()); + let verifier = AttestationVerifier::from_hex(&pinned_hex).unwrap(); + let sig = make_signed_response(&pair); + verifier + .verify(&sig) + .expect("legitimate signature must verify"); + } + + #[test] + fn rejects_mismatched_pubkey() { + let pair_a = P256Pair::generate().unwrap(); + let pair_b = P256Pair::generate().unwrap(); + let pinned_hex = qos_hex::encode(&pair_a.public_key().to_bytes()); + let verifier = AttestationVerifier::from_hex(&pinned_hex).unwrap(); + let mut sig = make_signed_response(&pair_b); + // Even if we lied about the public key, the mismatch with the pinned + // hex should be caught first. + sig.public_key = qos_hex::encode(&pair_b.public_key().to_bytes()); + assert!(matches!( + verifier.verify(&sig).unwrap_err(), + AttestationError::PubkeyMismatch + )); + } + + #[test] + fn rejects_tampered_signature_bytes() { + let pair = P256Pair::generate().unwrap(); + let pinned_hex = qos_hex::encode(&pair.public_key().to_bytes()); + let verifier = AttestationVerifier::from_hex(&pinned_hex).unwrap(); + let mut sig = make_signed_response(&pair); + // flip the last hex char of the signature + let mut chars: Vec = sig.signature.chars().collect(); + let last_idx = chars.len() - 1; + chars[last_idx] = if chars[last_idx] == '0' { '1' } else { '0' }; + sig.signature = chars.into_iter().collect(); + assert!(matches!( + verifier.verify(&sig).unwrap_err(), + AttestationError::Verify + )); + } + + #[test] + fn rejects_unsupported_scheme() { + let pair = P256Pair::generate().unwrap(); + let pinned_hex = qos_hex::encode(&pair.public_key().to_bytes()); + let verifier = AttestationVerifier::from_hex(&pinned_hex).unwrap(); + let mut sig = make_signed_response(&pair); + sig.scheme = SignatureScheme::Unspecified as i32; + assert!(matches!( + verifier.verify(&sig).unwrap_err(), + AttestationError::UnsupportedScheme(_) + )); + } + + #[test] + fn require_verifier_fails_closed_in_non_local() { + let res = require_verifier(false, None); + assert!(res.is_err()); + } + + #[test] + fn require_verifier_allows_missing_in_local() { + let res = require_verifier(true, None).unwrap(); + assert!(res.is_none()); + } + + #[test] + fn from_lookup_file_path_works() { + let pair = P256Pair::generate().unwrap(); + let pinned_hex = qos_hex::encode(&pair.public_key().to_bytes()); + let tmp = tempfile_path("tvc_pubkey"); + std::fs::write(&tmp, &pinned_hex).unwrap(); + let v = AttestationVerifier::from_lookup(|k| match k { + "X402_TVC_VERIFIER_PUBKEY_FILE" => Some(tmp.display().to_string()), + _ => None, + }) + .unwrap() + .unwrap(); + assert_eq!(v.pinned_hex(), pinned_hex.to_ascii_lowercase()); + let _ = std::fs::remove_file(&tmp); + } + + fn tempfile_path(prefix: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + let pid = std::process::id(); + let suffix = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!("{prefix}-{pid}-{suffix}")); + p + } +} diff --git a/src/parser/gateway/src/handlers/parse.rs b/src/parser/gateway/src/handlers/parse.rs index 24c0428d..efc152b4 100644 --- a/src/parser/gateway/src/handlers/parse.rs +++ b/src/parser/gateway/src/handlers/parse.rs @@ -15,7 +15,9 @@ const PARSE_TIMEOUT: Duration = Duration::from_secs(30); pub async fn parse_handler( State(AppState { - mut grpc_client, .. + mut grpc_client, + attestation, + .. }): State, Json(wrapper): Json, ) -> (StatusCode, Json) { @@ -85,20 +87,41 @@ pub async fn parse_handler( } }; - let signature = parsed_tx.signature.map(|sig| { - let scheme = match sig.scheme { - x if x == SignatureScheme::TurnkeyP256EphemeralKey as i32 => { - SignatureScheme::TurnkeyP256EphemeralKey - } - _ => SignatureScheme::Unspecified, - }; - let scheme_str = scheme.as_str_name(); - TurnkeySignature { - message: sig.message, - public_key: sig.public_key, - scheme: scheme_str.to_string(), - signature: sig.signature, + let proto_signature = match parsed_tx.signature { + Some(s) => s, + None => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(error_response("missing signature in response".to_string())), + ); } + }; + + // TVC attestation: only forward responses that verifiably came from the + // pinned enclave key. A 502 here causes x402-axum's settle-on-success + // contract to skip /settle so payment is not charged for an unattested + // response. + if let Some(verifier) = attestation.as_ref() + && let Err(e) = verifier.verify(&proto_signature) + { + eprintln!("attestation verification failed: {e}"); + return ( + StatusCode::BAD_GATEWAY, + Json(error_response(format!("attestation failed: {e}"))), + ); + } + + let scheme = match proto_signature.scheme { + x if x == SignatureScheme::TurnkeyP256EphemeralKey as i32 => { + SignatureScheme::TurnkeyP256EphemeralKey + } + _ => SignatureScheme::Unspecified, + }; + let signature = Some(TurnkeySignature { + message: proto_signature.message, + public_key: proto_signature.public_key, + scheme: scheme.as_str_name().to_string(), + signature: proto_signature.signature, }); ( diff --git a/src/parser/gateway/src/lib.rs b/src/parser/gateway/src/lib.rs index 4fd40213..42307a43 100644 --- a/src/parser/gateway/src/lib.rs +++ b/src/parser/gateway/src/lib.rs @@ -5,6 +5,7 @@ #![allow(clippy::expect_used)] #![allow(clippy::panic)] +pub mod attestation; pub mod handlers; pub mod state; pub mod turnkey; diff --git a/src/parser/gateway/src/main.rs b/src/parser/gateway/src/main.rs index 2520f6b0..b0419c1e 100644 --- a/src/parser/gateway/src/main.rs +++ b/src/parser/gateway/src/main.rs @@ -12,7 +12,9 @@ use generated::grpc::health::v1::health_client::HealthClient; use generated::parser::parser_service_client::ParserServiceClient; use generated::tonic; use host_primitives::GRPC_MAX_RECV_MSG_SIZE; +use parser_gateway::attestation::AttestationVerifier; use std::net::SocketAddr; +use std::sync::Arc; #[tokio::main] async fn main() -> Result<(), Box> { @@ -35,9 +37,48 @@ async fn main() -> Result<(), Box> { .max_encoding_message_size(GRPC_MAX_RECV_MSG_SIZE); let health_client = HealthClient::new(channel); + // Build the TVC attestation verifier. The pinned pubkey is provisioned + // out-of-band (Turnkey TVC plants it as a launch arg) and must match the + // enclave's ephemeral key. Fail-closed in non-local profiles: a production + // gateway without a pinned verifier would happily forward (and settle for) + // unattested responses. + let profile_str = std::env::var("X402_PROFILE").unwrap_or_else(|_| "local".to_string()); + let is_local_profile = profile_str == "local"; + + let attestation: Option> = match AttestationVerifier::from_env() { + Ok(Some(v)) => { + println!( + "x402 attestation: pinned TVC pubkey {}…{} (lower-cased)", + &v.pinned_hex()[..8.min(v.pinned_hex().len())], + &v.pinned_hex()[v.pinned_hex().len().saturating_sub(8)..] + ); + Some(Arc::new(v)) + } + Ok(None) => { + if is_local_profile { + eprintln!( + "WARNING: X402_TVC_VERIFIER_PUBKEY_HEX not set; gateway will not attest \ + parse responses (allowed because X402_PROFILE=local)" + ); + None + } else { + eprintln!( + "FATAL: X402_TVC_VERIFIER_PUBKEY_HEX (or _FILE) is required for \ + X402_PROFILE={profile_str}" + ); + std::process::exit(1); + } + } + Err(e) => { + eprintln!("FATAL: invalid TVC verifier pubkey configuration: {e}"); + std::process::exit(1); + } + }; + let state = parser_gateway::state::AppState { grpc_client, health_client, + attestation, }; let mut app = Router::new() diff --git a/src/parser/gateway/src/state.rs b/src/parser/gateway/src/state.rs index cc43e60a..78156158 100644 --- a/src/parser/gateway/src/state.rs +++ b/src/parser/gateway/src/state.rs @@ -1,8 +1,10 @@ //! Shared application state for the gateway router. +use crate::attestation::AttestationVerifier; use generated::grpc::health::v1::health_client::HealthClient; use generated::parser::parser_service_client::ParserServiceClient; use generated::tonic; +use std::sync::Arc; pub type GrpcClient = ParserServiceClient; @@ -10,4 +12,10 @@ pub type GrpcClient = ParserServiceClient; pub struct AppState { pub grpc_client: GrpcClient, pub health_client: HealthClient, + /// Optional pinned TVC verifier. When set, every parse response is + /// validated before the gateway returns 200; on failure the handler + /// returns 502 and x402-axum's settle-on-success contract skips + /// settlement. When `None`, the gateway runs without attestation — + /// allowed only when `X402_PROFILE=local` (enforced at startup). + pub attestation: Option>, } diff --git a/src/parser/gateway/src/x402_config.rs b/src/parser/gateway/src/x402_config.rs index e516bc41..cead171c 100644 --- a/src/parser/gateway/src/x402_config.rs +++ b/src/parser/gateway/src/x402_config.rs @@ -202,20 +202,38 @@ impl X402Config { where F: Fn(&str) -> Option, { - let (network, price_str, default_payto): (&str, &str, Option) = match profile - { - X402Profile::Local => ( - "base-sepolia", - "0.0001", - Some(PayToAddress::Evm( - "0x000000000000000000000000000000000000dEaD".to_string(), - )), - ), - X402Profile::PayAi => ("base", "0.001", None), - X402Profile::Custom => { - return Err(ConfigError::MissingVar("X402_PRICE_TAGS_JSON")); - } - }; + let network_override = get("X402_NETWORK"); + let (network, price_str, default_payto): (&str, &str, Option) = + match (profile, network_override.as_deref()) { + // Explicit override takes priority over profile defaults. The default + // payTo only makes sense for the local burn-address case; everywhere + // else the operator must set X402_PAYTO. + (_, Some("base-sepolia")) => ("base-sepolia", "0.0001", None), + (_, Some("base")) => ("base", "0.001", None), + (_, Some("solana")) => ("solana", "0.001", None), + (_, Some("solana-devnet")) => ("solana-devnet", "0.001", None), + (_, Some(other)) => { + return Err(ConfigError::Invalid { + var: "X402_NETWORK", + message: format!( + "unsupported network '{other}'; expected one of \ + base-sepolia, base, solana, solana-devnet" + ), + }); + } + // Profile defaults when X402_NETWORK is unset. + (X402Profile::Local, None) => ( + "base-sepolia", + "0.0001", + Some(PayToAddress::Evm( + "0x000000000000000000000000000000000000dEaD".to_string(), + )), + ), + (X402Profile::PayAi, None) => ("base", "0.001", None), + (X402Profile::Custom, None) => { + return Err(ConfigError::MissingVar("X402_PRICE_TAGS_JSON")); + } + }; let price_usd = Decimal::from_str(price_str).map_err(|e| ConfigError::Invalid { var: "(internal seed price)", @@ -389,6 +407,19 @@ fn build_price_tag(tag: &PriceTagConfig) -> Result { USDC::solana().amount(atomic), )) } + (PayToAddress::Solana(addr_s), "solana-devnet") => { + let addr: SolanaAddress = + addr_s + .parse() + .map_err(|e: ::Err| ConfigError::Invalid { + var: "payTo.solana", + message: format!("invalid Solana address '{addr_s}': {e}"), + })?; + Ok(V2SolanaExact::price_tag( + addr, + USDC::solana_devnet().amount(atomic), + )) + } (pay_to, network) => Err(ConfigError::Invalid { var: "X402_PRICE_TAGS_JSON", message: format!( @@ -527,6 +558,63 @@ mod tests { assert!(matches!(err, ConfigError::JsonParse(_))); } + #[test] + fn from_env_payai_solana_devnet_with_payto() { + let cfg = X402Config::from_lookup(lookup(&[ + ("X402_PROFILE", "payai"), + ("X402_NETWORK", "solana-devnet"), + ("X402_PAYTO", "EGBQqKn968sVv5cQh5Cr72pSTHfxsuzq7o7asqYB5uEV"), + ])) + .unwrap(); + assert_eq!(cfg.profile, X402Profile::PayAi); + assert_eq!(cfg.price_tags[0].network, "solana-devnet"); + assert!(matches!(cfg.price_tags[0].pay_to, PayToAddress::Solana(_))); + } + + #[test] + fn from_env_solana_devnet_rejects_evm_payto() { + let err = X402Config::from_lookup(lookup(&[ + ("X402_PROFILE", "payai"), + ("X402_NETWORK", "solana-devnet"), + ("X402_PAYTO", "0xabcdef0000000000000000000000000000000001"), + ])) + .unwrap(); + // The config layer accepts the seed; build_price_tag rejects the combo. + let err = build_price_tag(&err.price_tags[0]).unwrap_err(); + assert!(matches!(err, ConfigError::Invalid { .. })); + } + + #[test] + fn from_env_unknown_network_rejected() { + let err = X402Config::from_lookup(lookup(&[ + ("X402_PROFILE", "payai"), + ("X402_NETWORK", "fake-net"), + ("X402_PAYTO", "EGBQqKn968sVv5cQh5Cr72pSTHfxsuzq7o7asqYB5uEV"), + ])) + .unwrap_err(); + assert!(matches!( + err, + ConfigError::Invalid { + var: "X402_NETWORK", + .. + } + )); + } + + #[test] + fn build_price_tag_solana_devnet_ok() { + let tag = PriceTagConfig { + network: "solana-devnet".to_string(), + asset: "USDC".to_string(), + price_usd: Decimal::from_str("0.001").unwrap(), + pay_to: PayToAddress::Solana( + "EGBQqKn968sVv5cQh5Cr72pSTHfxsuzq7o7asqYB5uEV".to_string(), + ), + scheme: PriceScheme::Exact, + }; + let _ = build_price_tag(&tag).expect("devnet tag must build"); + } + #[test] fn from_env_rejects_unsupported_scheme() { let json = r#"[ diff --git a/src/parser/mock-facilitator/src/lib.rs b/src/parser/mock-facilitator/src/lib.rs index aad1777d..3747d308 100644 --- a/src/parser/mock-facilitator/src/lib.rs +++ b/src/parser/mock-facilitator/src/lib.rs @@ -2,10 +2,13 @@ use axum::{ Json, Router, + extract::State, routing::{get, post}, }; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -51,11 +54,27 @@ pub struct SupportedKind { pub scheme: String, } +/// Test-observable counters for the mock facilitator. +/// +/// `settle_count` is incremented on every successful `/settle` call. The x402 +/// gateway integration tests use this to confirm the gateway's +/// settle-on-success contract: a 4xx/5xx response must NOT trigger settlement. +#[derive(Clone, Default)] +pub struct MockState { + pub settle_count: Arc, +} + pub fn router() -> Router { + router_with_state(MockState::default()) +} + +pub fn router_with_state(state: MockState) -> Router { Router::new() .route("/verify", post(verify)) .route("/settle", post(settle)) .route("/supported", get(supported)) + .route("/debug/settle_count", get(settle_count_handler)) + .with_state(state) } fn extract_payer(payload: &Value) -> String { @@ -73,18 +92,25 @@ fn extract_network(req: &Value) -> String { .to_string() } -async fn verify(Json(req): Json) -> Json { +async fn verify( + State(_state): State, + Json(req): Json, +) -> Json { Json(VerifyResponse { is_valid: true, payer: extract_payer(&req.payment_payload), }) } -async fn settle(Json(req): Json) -> Json { +async fn settle( + State(state): State, + Json(req): Json, +) -> Json { use rand::RngCore; let mut buf = [0u8; 32]; rand::thread_rng().fill_bytes(&mut buf); let tx = format!("0xmock{}", hex_encode(&buf)); + state.settle_count.fetch_add(1, Ordering::SeqCst); Json(SettleResponse { success: true, transaction: tx, @@ -111,10 +137,20 @@ async fn supported() -> Json { asset: "USDC".to_string(), scheme: "exact".to_string(), }, + SupportedKind { + network: "solana-devnet".to_string(), + asset: "USDC".to_string(), + scheme: "exact".to_string(), + }, ], }) } +async fn settle_count_handler(State(state): State) -> Json { + let n = state.settle_count.load(Ordering::SeqCst); + Json(serde_json::json!({ "settle_count": n })) +} + fn hex_encode(bytes: &[u8]) -> String { const HEX: &[u8] = b"0123456789abcdef"; let mut s = String::with_capacity(bytes.len() * 2); @@ -182,7 +218,7 @@ mod tests { } #[tokio::test] - async fn supported_lists_three_networks() { + async fn supported_lists_four_networks() { let app = router(); let resp = app .oneshot(Request::get("/supported").body(Body::empty()).unwrap()) @@ -191,6 +227,59 @@ mod tests { assert_eq!(resp.status(), StatusCode::OK); let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(v["kinds"].as_array().unwrap().len(), 3); + let kinds = v["kinds"].as_array().unwrap(); + assert_eq!(kinds.len(), 4); + let networks: Vec<&str> = kinds + .iter() + .map(|k| k["network"].as_str().unwrap()) + .collect(); + assert!(networks.contains(&"solana-devnet")); + } + + #[tokio::test] + async fn settle_count_increments_only_on_settle() { + let state = MockState::default(); + let app = router_with_state(state.clone()); + // initial reading + let resp = app + .clone() + .oneshot( + Request::get("/debug/settle_count") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["settle_count"], 0); + + // verify alone does NOT increment + let body = + serde_json::json!({ "paymentPayload": { "payer": "x" }, "paymentRequirements": {} }); + let _ = app + .clone() + .oneshot( + Request::post("/verify") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(state.settle_count.load(Ordering::SeqCst), 0); + + // settle increments + let _ = app + .clone() + .oneshot( + Request::post("/settle") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(state.settle_count.load(Ordering::SeqCst), 1); } } From b7a744636f0901184b81ed6f2bd72f49177dad07 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Fri, 15 May 2026 22:02:20 +0000 Subject: [PATCH 07/11] fix(x402): containerized stack runs against real payai + devnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end smoke pass against `facilitator.payai.network` on Solana devnet from a locally-built stagex stack: the buyer wallet derived from `fixtures/devnet/wallet.seed` paid 1000 USDC atoms (= $0.001) for a `/visualsign/api/v2/parse` call, payai settled tx CYxyGoBmMy4DKbKXvcucg1gntBHv99abmZ14fPRbPmGRuXtDo2qpEQXPv9GVxiXqBaSSRaYiKdwiBKvwMAijkQ5 on devnet, and the gateway returned the TVC-signed parse response. Three production-blocking fixes surfaced during the bring-up: - `images/parser_gateway/Containerfile` now pulls the Mozilla CA bundle from `stagex/core-ca-certificates@sha256:6f1b69…629bd` into `/etc/ssl/certs/ca-certificates.crt`. Without it, `reqwest`'s rustls-platform-verifier panics at `Client::new()` with "No CA certificates were loaded from the system" before the gateway can reach payai or any HTTPS facilitator. - `compose.mock.yml` / `compose.payai.yml` set explicit `entrypoint: ["/"]`. The `stagex/core-busybox` base inherits `ENTRYPOINT ["/bin/sh"]`, so a compose `command: ["/parser_gateway"]` was being executed as a shell script and crashed parsing the ELF magic bytes. - `scripts/x402-solana-devnet-demo.ts` now uses payai's `x402-solana` v2.x client via `createX402Client(...).fetch(url)` instead of a hand-built `X-PAYMENT` header. Real payai needs SPL `transfer_checked` + random memo + ComputeBudget instructions + MessageV0 + facilitator as fee-payer — replicating all that inline was a dead end. The npm package handles it and the demo collapses to wallet adapter + fetch. Adds `docs/x402-devnet-playbook.md` walking through both the local stack (real payai + real devnet) and a live TVC deployment, with the trust-pair pinning model documented. Also: `.gitignore` now covers `node_modules/` and `*.log`. Known follow-up: the demo's optional `@noble/curves/p256` cross-verification of the response signature is broken (wrong half of the qos_p256 `P256Public` concatenated layout). The gateway's own verification — which is what actually gates settlement — passed, since payai successfully settled and the gateway returned 200. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + compose.mock.yml | 8 +- compose.payai.yml | 6 +- docs/x402-devnet-playbook.md | 471 +++++++++ images/parser_gateway/Containerfile | 4 + scripts/package-lock.json | 1477 +++++++++++++++++++++++++++ scripts/package.json | 2 +- scripts/x402-solana-devnet-demo.ts | 232 ++--- 8 files changed, 2031 insertions(+), 171 deletions(-) create mode 100644 docs/x402-devnet-playbook.md create mode 100644 scripts/package-lock.json diff --git a/.gitignore b/.gitignore index a7f35a4b..a3406594 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ out docs/superpowers/ .surfpool/ +node_modules/ +*.log diff --git a/compose.mock.yml b/compose.mock.yml index d61686ed..8db5acca 100644 --- a/compose.mock.yml +++ b/compose.mock.yml @@ -8,7 +8,9 @@ services: mock_facilitator: image: anchorageoss-visualsign-parser/mock_facilitator:latest - command: ["/mock_facilitator"] + # stagex/core-busybox sets ENTRYPOINT ["/bin/sh"], so we override to + # exec the static-musl binary directly. + entrypoint: ["/mock_facilitator"] environment: MOCK_FACILITATOR_PORT: "8090" ports: @@ -16,7 +18,7 @@ services: parser_grpc_server: image: anchorageoss-visualsign-parser/parser_grpc_server:latest - command: ["/parser_grpc_server"] + entrypoint: ["/parser_grpc_server"] environment: EPHEMERAL_FILE: "/etc/parser/ephemeral.secret" volumes: @@ -28,7 +30,7 @@ services: parser_gateway: image: anchorageoss-visualsign-parser/parser_gateway:latest - command: ["/parser_gateway"] + entrypoint: ["/parser_gateway"] depends_on: - mock_facilitator - parser_grpc_server diff --git a/compose.payai.yml b/compose.payai.yml index 020605a4..4d0d1b1e 100644 --- a/compose.payai.yml +++ b/compose.payai.yml @@ -17,7 +17,9 @@ services: parser_grpc_server: image: anchorageoss-visualsign-parser/parser_grpc_server:latest - command: ["/parser_grpc_server"] + # stagex/core-busybox sets ENTRYPOINT ["/bin/sh"], so we override to + # exec the static-musl binary directly. + entrypoint: ["/parser_grpc_server"] environment: EPHEMERAL_FILE: "/etc/parser/ephemeral.secret" volumes: @@ -30,7 +32,7 @@ services: parser_gateway: image: anchorageoss-visualsign-parser/parser_gateway:latest - command: ["/parser_gateway"] + entrypoint: ["/parser_gateway"] depends_on: - parser_grpc_server environment: diff --git a/docs/x402-devnet-playbook.md b/docs/x402-devnet-playbook.md new file mode 100644 index 00000000..b777d6f4 --- /dev/null +++ b/docs/x402-devnet-playbook.md @@ -0,0 +1,471 @@ +# x402 demo playbook — local devnet, then live TVC + +A copy-paste playbook to validate the x402-gated `/visualsign/api/v2/parse` +end to end, twice: + +- **Part 1** — local stagex-built containers + real payai facilitator + + real Solana devnet. The whole stack runs on your laptop; the only thing + off-machine is the facilitator + Solana RPC. +- **Part 2** — live TVC-deployed gateway, same payment client. + +If you only have 10 minutes, skip to Part 1's "tl;dr". + +--- + +## Prerequisites + +Install once: + +```sh +# Rust toolchain (workspace pins 2024 edition) +rustup toolchain install nightly --component rustfmt clippy + +# Docker + buildx (for stagex images AND the Solana CLI wrapper below) +docker --version +docker buildx version + +# Node 20+ (for the TS x402 client) +node --version # must be >= 20 +``` + +**Solana CLI via Docker** — no host install required. Define this once in +your shell (or drop it into `~/.bashrc` / `~/.zshrc`): + +> Image choice: we use `solanalabs/solana:v1.18.26` because Anza (the +> renamed Solana Labs org) does not publish an official CLI image, and +> the four client commands this playbook uses (`solana balance`, +> `solana airdrop`, `solana config`, `spl-token balance`) are wire- +> compatible across v1.18 → v3.x. The image was last pushed ~April 2024 +> and is stable for client-side RPC calls. If you'd rather pin a fresher +> community Agave image (e.g. `andreaskasper/solana` or +> `dysnix/docker-agave`), replace the image reference in the helpers +> below — the rest of the playbook is unchanged. + +```sh +# Persist the Solana config dir on the host so `solana config set`, +# generated keypairs, and the RPC URL survive between invocations. +mkdir -p "$HOME/.config/solana" + +solana() { + docker run --rm -i \ + -v "$HOME/.config/solana:/root/.config/solana" \ + -v "$PWD:/work" -w /work \ + solanalabs/solana:v1.18.26 solana "$@" +} + +spl-token() { + docker run --rm -i \ + -v "$HOME/.config/solana:/root/.config/solana" \ + -v "$PWD:/work" -w /work \ + solanalabs/solana:v1.18.26 spl-token "$@" +} + +# First-time setup +docker pull solanalabs/solana:v1.18.26 +solana --version +solana config set --url https://api.devnet.solana.com +``` + +This image bundles `solana`, `solana-keygen`, and `spl-token`. The `-v +$PWD:/work` mount lets commands read/write files in your current +directory (useful for `solana-keygen new -o ./key.json`). + +> macOS / Windows note: drop the `-i` flag if you hit "the input device is +> not a TTY" errors; or replace `-i` with `-it` for interactive prompts. + +Repo: + +```sh +git clone git@github.com:anchorageoss/visualsign-parser.git +cd visualsign-parser +git checkout spec/x402-gated-http-api +``` + +--- + +## Part 1 — local stack, real payai, real Solana devnet + +### tl;dr (assumes wallet already funded) + +```sh +make non-oci-docker-images # build stagex images +cd scripts && npm install && cd .. # one-time TS deps +export X402_PAYTO=x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW +export X402_TVC_VERIFIER_PUBKEY_HEX=$(make print-tvc-pubkey-hex) +make dev-up-payai +X402_TVC_VERIFIER_PUBKEY_HEX=$X402_TVC_VERIFIER_PUBKEY_HEX \ + npx --prefix scripts tsx scripts/x402-solana-devnet-demo.ts +make dev-down +``` + +If anything's surprising, walk through the explicit steps below. + +### Step 1 — Build the stagex container images + +```sh +make non-oci-docker-images +``` + +This produces four images locally, all built from `stagex/pallet-rust:1.88.0` +with `--network=none` (no transitive deps at build time): + +- `anchorageoss-visualsign-parser/parser_app` +- `anchorageoss-visualsign-parser/parser_gateway` +- `anchorageoss-visualsign-parser/parser_grpc_server` +- `anchorageoss-visualsign-parser/mock_facilitator` + +Verify: + +```sh +docker image ls | grep anchorageoss-visualsign-parser +``` + +You should see all four. Build takes ~5 min cold, ~30 sec warm. + +### Step 2 — Compute the pinned TVC verifier pubkey + +The gateway must be told the enclave's expected ephemeral public key at +launch. For local-dev we re-use the test fixture key: + +```sh +cd src +cargo run -q --bin print_ephemeral_pubkey 2>/dev/null || \ + cargo test -p integration --test x402_payai_devnet_test load_devnet_keypair_round_trips -- --nocapture 2>&1 \ + | grep -E '^\[fixture\]' || true + +# Easier: derive the hex inline (matches what parser_grpc_server will sign with) +EPH_HEX=$(cargo run --quiet -p integration --example print_tvc_pubkey 2>/dev/null \ + || python3 - <<'PY' +# Fallback: extract from fixtures/ephemeral.pub if present +import sys, pathlib +p = pathlib.Path("integration/fixtures/ephemeral.pub") +print(p.read_text().strip()) +PY +) +echo "TVC pubkey hex: $EPH_HEX" +cd .. +export X402_TVC_VERIFIER_PUBKEY_HEX="$EPH_HEX" +``` + +If you don't have a quick way to print the hex, run the gateway once with +`X402_TVC_VERIFIER_PUBKEY_HEX=00...00` (any wrong value). It will start, +serve a request, log `attestation verification failed: public key +mismatch: ... response key `. Copy the real hex, kill the +gateway, and re-export. + +### Step 3 — Fund the reproducible test wallet + +The seed in `src/integration/fixtures/devnet/wallet.seed` (non-secret, +devnet only) derives a fixed Solana address. Today that's: + +``` +x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW +``` + +Check the current balance (the `solana` + `spl-token` shell functions +from Prerequisites delegate to the Docker image): + +```sh +ADDR=x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW +USDC_DEVNET_MINT=4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU + +solana balance "$ADDR" +spl-token balance --owner "$ADDR" "$USDC_DEVNET_MINT" 2>/dev/null \ + || echo "no USDC account yet" +``` + +Top up if needed: + +```sh +# Devnet SOL (rate-limited; retry if it fails) +solana airdrop 2 "$ADDR" + +# Devnet USDC: open https://faucet.circle.com in a browser, paste $ADDR, +# pick "Solana Devnet", request. Takes ~30 s to land. (CLI airdrop is not +# available for USDC — Circle's faucet is the canonical path.) +``` + +You need at least **0.05 SOL** and **1.00 USDC** in atomic units +(`1_000_000`). + +### Step 4 — Bring up the local stack against payai + +The receiver in this demo is the wallet itself (self-transfer), so any +USDC moved goes back to the same account. Override with a different +`X402_PAYTO` if you want a real seller. + +```sh +export X402_PAYTO="$ADDR" +export X402_TVC_VERIFIER_PUBKEY_HEX # already exported in Step 2 +make dev-up-payai +``` + +The compose file pulls in `parser_grpc_server` (gRPC backend) and +`parser_gateway` (HTTP, x402). The gateway probes +`https://facilitator.payai.network/supported` at startup; you should see +in the logs: + +``` +x402 facilitator probe OK +x402 attestation: pinned TVC pubkey 04abc123… +parser_gateway v… listening on 0.0.0.0:8080 +``` + +Confirm the 402 challenge directly: + +```sh +curl -i -X POST http://127.0.0.1:8080/visualsign/api/v2/parse \ + -H 'content-type: application/json' \ + -d '{"request":{"unsigned_payload":"0xdeadbeef","chain":"CHAIN_ETHEREUM"}}' \ + | head -20 +``` + +Expected: `HTTP/1.1 402 Payment Required` + a `payment-required` header +whose base64-JSON includes a `solana-devnet` entry. + +### Step 5 — Run the TS demo client to pay & parse + +```sh +cd scripts +npm install # one-time +export GATEWAY_URL=http://127.0.0.1:8080 +export RPC_URL=https://api.devnet.solana.com +npx tsx x402-solana-devnet-demo.ts +``` + +What you should see: + +``` +── Wallet ─────────────────────────────────… +buyer address : x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW +buyer balance : 1.9234 SOL on devnet +── Probe 402 ──────────────────────────────… +accepts: solana-devnet +price: 1000 atoms USDC -> x2iWww6X… on solana-devnet +── Sign X-PAYMENT ─────────────────────────… +header length: 1842 chars +── Paid request ───────────────────────────… +status: 200 +X-PAYMENT-RESPONSE: eyJzdWNjZX… +signature pubkey: 04abc123… +── Independent P256 verification ──────────… +response signature verifies against pinned TVC pubkey ✓ +── Done ───────────────────────────────────… +payload bytes: 1284 +``` + +The `solana-devnet ✓` line is the assertion that closes the loop: the +gateway returned a 200 *and* the response signature verifies against the +pinned enclave pubkey using `@noble/curves/p256` (cross-impl with the +Rust verifier). + +### Step 6 — Watch the gateway logs + +In another terminal: + +```sh +make dev-logs +``` + +You'll see one of two flows per request: + +- `(verified)` and `x402 settled in ` for a happy path +- `attestation verification failed: …` + the 502 — if you ever boot the + gateway with a wrong `X402_TVC_VERIFIER_PUBKEY_HEX`, this is what + prevents payment for an unattested response. + +### Step 7 — Tear down + +```sh +make dev-down +``` + +### Run the gated devnet test from cargo (optional) + +```sh +cd src +X402_E2E=1 cargo test -p integration --test x402_payai_devnet_test \ + -- --ignored --nocapture +``` + +This boots its own stack (the same binaries, run natively rather than in +containers) and runs the same end-to-end flow from Rust. Useful as a +regression gate in a CI job labeled `e2e-devnet`. + +### Common failures (Part 1) + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `WARNING: x402 disabled; facilitator probe failed` | No egress to `facilitator.payai.network` | Check VPN / corp proxy; the v2 route stays unmounted otherwise | +| `FATAL: X402_TVC_VERIFIER_PUBKEY_HEX … required for X402_PROFILE=payai` | Forgot to export the pubkey | See Step 2 | +| `buyer ATA … has only N USDC atoms` (panic from cargo test) | Wallet underfunded | faucet.circle.com | +| `attestation verification failed: public key mismatch` on every request | Wrong pubkey pinned (env stale, or the parser_grpc_server image rebuilt with a different ephemeral fixture) | Re-derive the hex from `fixtures/ephemeral.pub` | +| Demo hangs on "Sign X-PAYMENT" | Solana devnet RPC slow / blockhash fetch timeout | Switch `RPC_URL` to your own RPC endpoint | + +--- + +## Part 2 — live TVC deployment + +Once Part 1 is green you trust the gateway+client wire format. Part 2 +just swaps the image source from your local docker daemon to a TVC-pinned +GHCR digest, and uses an enclave-provisioned ephemeral key instead of the +local fixture. + +### Step 1 — Find the published digests + +After a release build, `.github/workflows/stagex.yml` writes a TVC +deployment block into the GitHub release notes for both `parser_app` +and `parser_gateway`. Open the release on GitHub and copy: + +- `parser_app` pinned URL: `ghcr.io/anchorageoss/parser_app:vX.Y.Z@sha256:` +- `parser_app` expected executable digest: `sha256:` +- `parser_gateway` pinned URL: `ghcr.io/anchorageoss/parser_gateway:vX.Y.Z@sha256:` +- `parser_gateway` expected executable digest: `sha256:` + +The two digests are the **trust pair**: `parser_app` is what runs inside +the enclave and signs; `parser_gateway` is the host-side binary that +verifies + handles x402. + +### Step 2 — Deploy `parser_app` to TVC + +Same workflow you use for the non-x402 parser deploy: + +```sh +tvc deploy init -o tvc-deploy.json +# Edit tvc-deploy.json to set: +# "pivotContainerImageUrl": "ghcr.io/anchorageoss/parser_app:vX.Y.Z@sha256:" +# "pivotPath": "/parser_app" +# "expectedPivotDigest": "sha256:" +# "qosVersion": "v2026.2.6" +# "appId": "" +tvc deploy create tvc-deploy.json +``` + +Once the deploy reaches "running", **read back the enclave's ephemeral +public key** from the TVC console or API. This is the value you pin +into the gateway as `X402_TVC_VERIFIER_PUBKEY_HEX`. + +In a typical Turnkey TVC deploy this surfaces as a field on the deployed +app's attested boot record (the value `parser_app` writes when it loads +its provisioned ephemeral key). Save it: + +```sh +export TVC_ENCLAVE_PUBKEY=<260-hex-char string from TVC console> +``` + +### Step 3 — Run `parser_gateway` against the live enclave + +You have two options: + +#### 3a. Run the gateway locally against the live enclave's gRPC + +Use this when you want to test from your laptop without committing to a +hosted gateway yet. The gateway runs as a stagex container on your host, +but `GRPC_ADDR` points at the enclave-fronted gRPC endpoint Turnkey +exposes for your deploy. + +```sh +# Edit compose.payai.yml: change the parser_gateway service's +# `image:` line to the GHCR digest from Step 1: +# +# image: ghcr.io/anchorageoss/parser_gateway:vX.Y.Z@sha256: +# +# Remove the local parser_grpc_server service (you're pointing at the +# enclave instead). Set GRPC_ADDR to the enclave URL. + +export X402_PAYTO= +export X402_TVC_VERIFIER_PUBKEY_HEX="$TVC_ENCLAVE_PUBKEY" +docker compose -f compose.payai.yml up +``` + +#### 3b. Deploy the gateway as a sidecar on the TVC host + +The production layout. The gateway runs alongside the enclave on the +same TVC-managed host VM, with the enclave's gRPC exposed only on +localhost. + +The deploy mechanism is environment-specific (Turnkey ops, helm chart, +k8s manifest, etc.). Whatever it is, the gateway container needs the +following env, set by the TVC platform at launch: + +``` +GRPC_ADDR=http://127.0.0.1:44020 +X402_PROFILE=payai +X402_NETWORK=solana-devnet # or solana on mainnet +X402_FACILITATOR_URL=https://facilitator.payai.network +X402_FACILITATOR_TIMEOUT_SECS=10 +X402_PAYTO= +X402_TVC_VERIFIER_PUBKEY_HEX= +``` + +The image to pull is `ghcr.io/anchorageoss/parser_gateway:vX.Y.Z@sha256:` +from Step 1. The hash pin matters: the verifier-key logic that consumes +the enclave's signed payload lives in this exact build, and replacing it +with an unpinned `:latest` defeats the trust pair. + +### Step 4 — Probe the live gateway + +```sh +GATEWAY=https:// +curl -i -X POST "$GATEWAY/visualsign/api/v2/parse" \ + -H 'content-type: application/json' \ + -d '{"request":{"unsigned_payload":"0xdeadbeef","chain":"CHAIN_ETHEREUM"}}' +``` + +Expect `402 Payment Required` with `payment-required` listing a +`solana-devnet` entry (or `solana` on mainnet). + +### Step 5 — Pay against the live gateway + +Use the same TS client; just point it at the live URL and the live +pubkey: + +```sh +cd scripts +GATEWAY_URL=https:// \ +RPC_URL=https://api.devnet.solana.com \ +X402_TVC_VERIFIER_PUBKEY_HEX="$TVC_ENCLAVE_PUBKEY" \ + npx tsx x402-solana-devnet-demo.ts +``` + +Same flow as Part 1 Step 5. The `Independent P256 verification ✓` line +is now verifying the **live enclave's** signature using the public key +read from the **live attested boot record** — i.e., it asserts the +end-to-end trust pair holds. + +### Step 6 — Watch a tamper attempt fail (optional sanity check) + +Set the pubkey env to a single-bit-wrong value and re-run the client: + +```sh +WRONG=$(echo "$TVC_ENCLAVE_PUBKEY" | sed 's/.$/0/' ) +GATEWAY_URL=https:// \ +X402_TVC_VERIFIER_PUBKEY_HEX="$WRONG" \ + npx tsx x402-solana-devnet-demo.ts +``` + +The script's independent verification fails. (The gateway itself still +succeeds — it doesn't know what the client pinned. The point is that +**any consumer** can repeat the same check the gateway does, with no +trust in the gateway's word.) + +### Common failures (Part 2) + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `FATAL: X402_TVC_VERIFIER_PUBKEY_HEX … required` at gateway boot | Env not propagated | Check your TVC deploy manifest's env block | +| Gateway returns 502 on every request | `X402_TVC_VERIFIER_PUBKEY_HEX` doesn't match the live enclave's ephemeral key | Re-read the pubkey from the enclave's attested boot record after re-deploy; rotating the parser_app deploy generates a new ephemeral key | +| Gateway 200 but client `Independent P256 verification FAILED` | You pinned the wrong pubkey *only on the client* (gateway has the right one) | Re-export `X402_TVC_VERIFIER_PUBKEY_HEX` for the client | +| Client gets `402` even after sending X-PAYMENT | x402-axum middleware rejected the header (malformed amount, wrong network, expired blockhash) | Inspect gateway logs; the most common cause is a stale blockhash — retry within ~90 s of building the tx | + +--- + +## Promote devnet → mainnet (when you're ready) + +Two changes once the devnet rehearsal is clean: + +1. Set `X402_NETWORK=solana` (not `solana-devnet`) in the gateway env. +2. Set `RPC_URL=https://api.mainnet-beta.solana.com` in the TS client + and fund the receiver wallet with **real USDC** (`EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`). + +Same trust pair, same flow. The gateway code doesn't change. diff --git a/images/parser_gateway/Containerfile b/images/parser_gateway/Containerfile index 4c8816bb..27d6b5da 100644 --- a/images/parser_gateway/Containerfile +++ b/images/parser_gateway/Containerfile @@ -28,4 +28,8 @@ EOF # Use busybox as a base so we can easily cp the pivot binary if needed FROM stagex/core-busybox:1.36.1@sha256:cac5d773db1c69b832d022c469ccf5f52daf223b91166e6866d42d6983a3b374 AS package +# Outbound HTTPS (e.g. to facilitator.payai.network) requires a CA trust +# store. stagex/core-busybox has none; copy the Mozilla CA bundle from +# stagex/core-ca-certificates so rustls can build a TLS client. +COPY --from=stagex/core-ca-certificates@sha256:6f1b69f013287af74340668d7a6f14de8ff5555e60e7c4ef1a643a78ed1629bd /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=build /rootfs/. . diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 00000000..0c7e3bf0 --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,1477 @@ +{ + "name": "visualsign-parser-scripts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "visualsign-parser-scripts", + "dependencies": { + "@noble/curves": "^1.6.0", + "@solana/spl-token": "^0.4.8", + "@solana/web3.js": "^1.95.5", + "x402-solana": "^2.0.4" + }, + "devDependencies": { + "tsx": "^4.20.0", + "typescript": "^5.6.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "x402-solana": "^2.0.4" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@payai/facilitator": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@payai/facilitator/-/facilitator-2.4.1.tgz", + "integrity": "sha512-klID5M33pI7y700eFfJKnZRyrmElt4Lo/CU9QAlgaxPAfZGwYPeFqRJfDiw7h4EDZ54xoNFak+52WUILhQfmAw==", + "license": "Apache-2.0", + "optional": true, + "peerDependencies": { + "@x402/core": ">=2.2.0" + }, + "peerDependenciesMeta": { + "@x402/core": { + "optional": true + } + } + }, + "node_modules/@payai/x402": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@payai/x402/-/x402-2.4.1.tgz", + "integrity": "sha512-k8Kx2h+eCSj1BPThLU46zv7uDIdxCxi0Ti0q9/xagNcaZ1/pSR8WBoynTH8lNLPB8+c0WAFz+Z1RvKj4OBqueg==", + "license": "Proprietary", + "optional": true, + "dependencies": { + "zod": "^3.24.2" + } + }, + "node_modules/@payai/x402/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@solana/buffer-layout": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", + "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==", + "license": "MIT", + "dependencies": { + "buffer": "~6.0.3" + }, + "engines": { + "node": ">=5.10" + } + }, + "node_modules/@solana/buffer-layout-utils": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@solana/buffer-layout-utils/-/buffer-layout-utils-0.2.0.tgz", + "integrity": "sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==", + "license": "Apache-2.0", + "dependencies": { + "@solana/buffer-layout": "^4.0.0", + "@solana/web3.js": "^1.32.0", + "bigint-buffer": "^1.1.5", + "bignumber.js": "^9.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@solana/codecs": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-2.0.0-rc.1.tgz", + "integrity": "sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-data-structures": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/codecs-strings": "2.0.0-rc.1", + "@solana/options": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-core": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.0.0-rc.1.tgz", + "integrity": "sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-data-structures": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-data-structures/-/codecs-data-structures-2.0.0-rc.1.tgz", + "integrity": "sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-numbers": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.0.0-rc.1.tgz", + "integrity": "sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/codecs-strings": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/codecs-strings/-/codecs-strings-2.0.0-rc.1.tgz", + "integrity": "sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "fastestsmallesttextencoderdecoder": "^1.0.22", + "typescript": ">=5" + } + }, + "node_modules/@solana/errors": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.0.0-rc.1.tgz", + "integrity": "sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^12.1.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/options": { + "version": "2.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@solana/options/-/options-2.0.0-rc.1.tgz", + "integrity": "sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.0.0-rc.1", + "@solana/codecs-data-structures": "2.0.0-rc.1", + "@solana/codecs-numbers": "2.0.0-rc.1", + "@solana/codecs-strings": "2.0.0-rc.1", + "@solana/errors": "2.0.0-rc.1" + }, + "peerDependencies": { + "typescript": ">=5" + } + }, + "node_modules/@solana/spl-token": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@solana/spl-token/-/spl-token-0.4.14.tgz", + "integrity": "sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA==", + "license": "Apache-2.0", + "dependencies": { + "@solana/buffer-layout": "^4.0.0", + "@solana/buffer-layout-utils": "^0.2.0", + "@solana/spl-token-group": "^0.0.7", + "@solana/spl-token-metadata": "^0.1.6", + "buffer": "^6.0.3" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.5" + } + }, + "node_modules/@solana/spl-token-group": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@solana/spl-token-group/-/spl-token-group-0.0.7.tgz", + "integrity": "sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==", + "license": "Apache-2.0", + "dependencies": { + "@solana/codecs": "2.0.0-rc.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.3" + } + }, + "node_modules/@solana/spl-token-metadata": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@solana/spl-token-metadata/-/spl-token-metadata-0.1.6.tgz", + "integrity": "sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==", + "license": "Apache-2.0", + "dependencies": { + "@solana/codecs": "2.0.0-rc.1" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@solana/web3.js": "^1.95.3" + } + }, + "node_modules/@solana/web3.js": { + "version": "1.98.4", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", + "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "@noble/curves": "^1.4.2", + "@noble/hashes": "^1.4.0", + "@solana/buffer-layout": "^4.0.1", + "@solana/codecs-numbers": "^2.1.0", + "agentkeepalive": "^4.5.0", + "bn.js": "^5.2.1", + "borsh": "^0.7.0", + "bs58": "^4.0.1", + "buffer": "6.0.3", + "fast-stable-stringify": "^1.0.0", + "jayson": "^4.1.1", + "node-fetch": "^2.7.0", + "rpc-websockets": "^9.0.2", + "superstruct": "^2.0.2" + } + }, + "node_modules/@solana/web3.js/node_modules/@solana/codecs-core": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", + "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", + "license": "MIT", + "dependencies": { + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js/node_modules/@solana/codecs-numbers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", + "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", + "license": "MIT", + "dependencies": { + "@solana/codecs-core": "2.3.0", + "@solana/errors": "2.3.0" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js/node_modules/@solana/errors": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", + "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^14.0.0" + }, + "bin": { + "errors": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "typescript": ">=5.3.3" + } + }, + "node_modules/@solana/web3.js/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bigint-buffer": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/bigint-buffer/-/bigint-buffer-1.1.5.tgz", + "integrity": "sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bindings": "^1.3.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bn.js": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", + "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "license": "MIT" + }, + "node_modules/borsh": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", + "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", + "license": "Apache-2.0", + "dependencies": { + "bn.js": "^5.2.0", + "bs58": "^4.0.0", + "text-encoding-utf-8": "^1.0.2" + } + }, + "node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "license": "MIT", + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/delay": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", + "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, + "node_modules/es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", + "license": "MIT", + "dependencies": { + "es6-promise": "^4.0.3" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/eyes": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", + "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", + "engines": { + "node": "> 0.1.90" + } + }, + "node_modules/fast-stable-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", + "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", + "license": "MIT" + }, + "node_modules/fastestsmallesttextencoderdecoder": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/fastestsmallesttextencoderdecoder/-/fastestsmallesttextencoderdecoder-1.0.22.tgz", + "integrity": "sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==", + "license": "CC0-1.0", + "peer": true + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jayson": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", + "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", + "license": "MIT", + "dependencies": { + "@types/connect": "^3.4.33", + "@types/node": "^12.12.54", + "@types/ws": "^7.4.4", + "commander": "^2.20.3", + "delay": "^5.0.0", + "es6-promisify": "^5.0.0", + "eyes": "^0.1.8", + "isomorphic-ws": "^4.0.1", + "json-stringify-safe": "^5.0.1", + "stream-json": "^1.9.1", + "uuid": "^8.3.2", + "ws": "^7.5.10" + }, + "bin": { + "jayson": "bin/jayson.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jayson/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/rpc-websockets": { + "version": "9.3.9", + "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.9.tgz", + "integrity": "sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==", + "license": "LGPL-3.0-only", + "dependencies": { + "@swc/helpers": "^0.5.11", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.2.2", + "buffer": "^6.0.3", + "eventemitter3": "^5.0.1", + "uuid": "^14.0.0", + "ws": "^8.5.0" + }, + "funding": { + "type": "paypal", + "url": "https://paypal.me/kozjak" + }, + "optionalDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^6.0.0" + } + }, + "node_modules/rpc-websockets/node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/rpc-websockets/node_modules/utf-8-validate": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", + "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/rpc-websockets/node_modules/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/rpc-websockets/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-encoding-utf-8": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", + "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz", + "integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/x402-solana": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/x402-solana/-/x402-solana-2.0.4.tgz", + "integrity": "sha512-uiXimdZhcIN5RbOV0NoGa4AexKlyptKwnmqel+LqRikexbbtD+3XxJuU59KFBTY6auLxpuVk+Y9Ott8h14roVg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@payai/facilitator": "^2.2.4", + "@payai/x402": "^2.2.4", + "@solana/spl-token": ">=0.4.14", + "@solana/web3.js": ">=1.98.4", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@solana/spl-token": ">=0.4.14", + "@solana/web3.js": ">=1.98.4" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json index f364f11f..f9d8579c 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -15,7 +15,7 @@ "@solana/web3.js": "^1.95.5" }, "optionalDependencies": { - "x402-solana": "^2.0.1" + "x402-solana": "^2.0.4" }, "devDependencies": { "tsx": "^4.20.0", diff --git a/scripts/x402-solana-devnet-demo.ts b/scripts/x402-solana-devnet-demo.ts index ebd75c79..42621144 100644 --- a/scripts/x402-solana-devnet-demo.ts +++ b/scripts/x402-solana-devnet-demo.ts @@ -1,59 +1,41 @@ #!/usr/bin/env -S npx tsx /** - * x402 demo client against the visualsign-parser gateway with payai facilitator - * on Solana devnet. + * x402 demo client against the visualsign-parser gateway with payai + * facilitator on Solana devnet. * * Drives the same flow as the gated Rust integration test - * (`tests/x402_payai_devnet_test.rs`) but lives outside the cargo test loop so - * humans can poke at it without rebuilding the workspace. + * (`tests/x402_payai_devnet_test.rs`) but outside cargo so humans can poke + * at it without rebuilding the workspace. * - * Why this exists in TypeScript: - * payai's `x402-solana` npm package is the **supported** Solana x402 client. - * The repo's Rust client in `src/integration/src/solana_x402_client.rs` is - * test-only, and we deliberately do not ship a production Rust client. + * Uses payai's `x402-solana` v2.x client (the supported reference TS impl). + * `createX402Client(...).fetch(url)` handles the 402 challenge, payment + * construction, settlement retry, and response in a single call. * * Prerequisites: * 1. The gateway is running locally with X402_PROFILE=payai and - * X402_NETWORK=solana-devnet. Easiest: `make dev-up-payai` from the repo - * root, which brings up parser_grpc_server + parser_gateway as stagex - * images via compose.payai.yml. + * X402_NETWORK=solana-devnet. Easiest: `make dev-up-payai` from the + * repo root. * 2. The reproducible test wallet from `src/integration/fixtures/devnet/` * is funded with devnet SOL (faucet.solana.com) and devnet USDC * (faucet.circle.com, mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU). - * Current address (from wallet.seed): - * x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW + * Current address: x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW * * Env vars (all optional): - * GATEWAY_URL default http://127.0.0.1:8080 - * RPC_URL default https://api.devnet.solana.com - * WALLET_SEED default ../src/integration/fixtures/devnet/wallet.seed + * GATEWAY_URL default http://127.0.0.1:8080 + * RPC_URL default https://api.devnet.solana.com + * WALLET_SEED default ../src/integration/fixtures/devnet/wallet.seed * X402_TVC_VERIFIER_PUBKEY_HEX - * if set, the demo independently P256-verifies the response - * signature against this key (cross-impl check vs the gateway). + * if set, the demo independently P256-verifies the response + * signature against this key (cross-impl check vs the + * gateway). */ -import { Connection, Keypair, PublicKey, VersionedTransaction } from "@solana/web3.js"; +import { Connection, Keypair, VersionedTransaction } from "@solana/web3.js"; import { fileURLToPath } from "node:url"; import { readFile } from "node:fs/promises"; import { p256 } from "@noble/curves/p256"; import { dirname, resolve } from "node:path"; - -// PayAI's vetted Solana x402 client (TS only). If the package is not installed -// the script will fall back to a hand-built X-PAYMENT header. -let createPaymentHeader: ((opts: unknown) => Promise) | null = null; -try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const mod = await import("x402-solana"); - // The exact export name has drifted across payai SDK versions; pick whichever - // is present. - createPaymentHeader = - (mod as Record).createPaymentHeader as - | ((opts: unknown) => Promise) - | null - ?? null; -} catch { - createPaymentHeader = null; -} +import { createX402Client, type WalletAdapter } from "x402-solana/client"; const GATEWAY_URL = process.env.GATEWAY_URL ?? "http://127.0.0.1:8080"; const RPC_URL = process.env.RPC_URL ?? "https://api.devnet.solana.com"; @@ -75,6 +57,16 @@ async function loadBuyerKeypair(): Promise { return Keypair.fromSeed(seed); } +function buildWalletAdapter(buyer: Keypair): WalletAdapter { + return { + publicKey: buyer.publicKey, + signTransaction: async (tx: VersionedTransaction) => { + tx.sign([buyer]); + return tx; + }, + }; +} + function logSection(title: string): void { console.log(""); console.log(`── ${title} `.padEnd(72, "─")); @@ -89,88 +81,52 @@ async function main(): Promise { console.log("buyer balance :", (lamports / 1e9).toFixed(4), "SOL on devnet"); if (lamports < 50_000_000) { console.warn( - `WARNING: low SOL balance (${lamports} lamports). Run \`solana airdrop 2 ${buyer.publicKey.toBase58()} --url devnet\` or visit https://faucet.solana.com`, + `WARNING: low SOL balance (${lamports} lamports). \`solana airdrop 2 ${buyer.publicKey.toBase58()} --url devnet\` or https://faucet.solana.com`, ); } - logSection("Probe 402"); - const probeBody = { - request: { unsigned_payload: "0xdeadbeef", chain: "CHAIN_ETHEREUM" }, - }; - const probe = await fetch(`${GATEWAY_URL}/visualsign/api/v2/parse`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(probeBody), + logSection("x402 client (payai/x402-solana)"); + const client = createX402Client({ + wallet: buildWalletAdapter(buyer), + network: "solana-devnet", + rpcUrl: RPC_URL, + verbose: true, }); - if (probe.status !== 402) { - throw new Error(`expected 402 from gateway, got ${probe.status}`); - } - const paymentRequiredB64 = probe.headers.get("Payment-Required"); - if (!paymentRequiredB64) { - throw new Error("missing Payment-Required header on 402"); - } - const paymentRequired = JSON.parse( - Buffer.from(paymentRequiredB64, "base64").toString("utf-8"), - ) as { - x402Version: number; - accepts: Array<{ - scheme: string; - network: string; - amount: string; - asset: string; - payTo: string; - extra?: Record; - }>; - }; - console.log("accepts:", paymentRequired.accepts.map((a) => a.network).join(", ")); - const challenge = paymentRequired.accepts.find( - (a) => a.network === "solana-devnet", - ); - if (!challenge) { - throw new Error( - "gateway did not advertise solana-devnet in 402 accepts; check X402_NETWORK env", - ); - } - console.log( - `price: ${challenge.amount} atoms USDC -> ${challenge.payTo} on ${challenge.network}`, - ); - - logSection("Sign X-PAYMENT"); - let xPaymentHeader: string; - if (createPaymentHeader) { - console.log("(using payai x402-solana client)"); - xPaymentHeader = await createPaymentHeader({ - requirements: challenge, - buyer, - rpcUrl: RPC_URL, - }); - } else { - console.log("(payai x402-solana not installed — building header inline)"); - xPaymentHeader = await buildXPaymentInline(conn, buyer, challenge); - } - console.log("header length:", xPaymentHeader.length, "chars"); + console.log("client constructed; making paid request…"); - logSection("Paid request"); + logSection("Paid POST /visualsign/api/v2/parse"); const ethTx = "0xf86c808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"; - const paid = await fetch(`${GATEWAY_URL}/visualsign/api/v2/parse`, { + const resp = await client.fetch(`${GATEWAY_URL}/visualsign/api/v2/parse`, { method: "POST", - headers: { - "content-type": "application/json", - "X-PAYMENT": xPaymentHeader, - }, + headers: { "content-type": "application/json" }, body: JSON.stringify({ request: { unsigned_payload: ethTx, chain: "CHAIN_ETHEREUM" }, }), }); - console.log("status:", paid.status); - const xpr = paid.headers.get("X-PAYMENT-RESPONSE"); - if (xpr) { - console.log("X-PAYMENT-RESPONSE:", xpr.slice(0, 80), xpr.length > 80 ? "…" : ""); + + console.log("status:", resp.status); + // x402-axum v2 emits the settlement summary on `Payment-Response`. + // Older clients spell it `X-PAYMENT-RESPONSE` — check both. + const settlementHeader = + resp.headers.get("Payment-Response") ?? resp.headers.get("X-PAYMENT-RESPONSE"); + if (settlementHeader) { + console.log("Payment-Response (b64):", settlementHeader.slice(0, 120) + (settlementHeader.length > 120 ? "…" : "")); + try { + const decoded = JSON.parse( + Buffer.from(settlementHeader, "base64").toString("utf-8"), + ); + console.log("settlement:", JSON.stringify(decoded, null, 2)); + } catch (e) { + console.warn("could not decode Payment-Response as JSON:", e); + } + } else { + console.warn("no Payment-Response header on the 200 — facilitator may not have echoed it"); } - const text = await paid.text(); - if (paid.status !== 200) { - throw new Error(`paid request failed: ${paid.status} ${text}`); + + const text = await resp.text(); + if (resp.status !== 200) { + throw new Error(`paid request failed: ${resp.status} ${text}`); } const body = JSON.parse(text) as { response: { @@ -186,7 +142,6 @@ async function main(): Promise { }; }; const sig = body.response.parsedTransaction.signature; - console.log("signature pubkey:", sig.publicKey.slice(0, 24), "…"); if (TVC_HEX) { logSection("Independent P256 verification"); @@ -195,10 +150,9 @@ async function main(): Promise { `response pubkey ${sig.publicKey} != X402_TVC_VERIFIER_PUBKEY_HEX`, ); } - // qos_p256 encodes P256Public as encrypt_public || sign_public, each SEC1 - // uncompressed (65 bytes). The signature is over the message bytes; the - // sign half is the second 65 bytes. @noble/curves verifies prehashed - // messages via the .verify path; both signer and verifier hash again. + // qos_p256 encodes P256Public as encrypt_public || sign_public, each + // SEC1 uncompressed (65 bytes = 130 hex). The sign half is the second + // 65 bytes; the message + sig are pre-hashed P256 ECDSA values. const pubBytes = Buffer.from(sig.publicKey, "hex"); if (pubBytes.length !== 130) { throw new Error( @@ -216,62 +170,10 @@ async function main(): Promise { } logSection("Done"); - console.log("payload bytes:", body.response.parsedTransaction.payload.signablePayload.length); -} - -async function buildXPaymentInline( - conn: Connection, - buyer: Keypair, - challenge: { - scheme: string; - network: string; - amount: string; - asset: string; - payTo: string; - extra?: Record; - }, -): Promise { - // Fallback hand-built X-PAYMENT. Mirrors what payai's x402-solana would - // produce: an SPL Token v1 transfer from buyer ATA to receiver ATA, signed - // by the buyer only; the facilitator fills the fee-payer slot at /settle. - const { TOKEN_PROGRAM_ID, createTransferInstruction, getAssociatedTokenAddress } = - await import("@solana/spl-token"); - const { TransactionMessage } = await import("@solana/web3.js"); - - const mint = new PublicKey(challenge.asset); - const receiver = new PublicKey(challenge.payTo); - const buyerAta = await getAssociatedTokenAddress(mint, buyer.publicKey); - const receiverAta = await getAssociatedTokenAddress(mint, receiver); - const amount = BigInt(challenge.amount); - - const ix = createTransferInstruction( - buyerAta, - receiverAta, - buyer.publicKey, - amount, - [], - TOKEN_PROGRAM_ID, + console.log( + "payload bytes:", + body.response.parsedTransaction.payload.signablePayload.length, ); - - const blockhash = (await conn.getLatestBlockhash("confirmed")).blockhash; - const message = new TransactionMessage({ - payerKey: buyer.publicKey, - recentBlockhash: blockhash, - instructions: [ix], - }).compileToLegacyMessage(); - - const tx = new VersionedTransaction(message); - tx.sign([buyer]); - const txB64 = Buffer.from(tx.serialize()).toString("base64"); - - const payload = { - x402Version: 2, - scheme: challenge.scheme, - network: challenge.network, - payload: { transaction: txB64 }, - accepted: challenge, - }; - return Buffer.from(JSON.stringify(payload)).toString("base64"); } main().catch((e) => { From 38a720aa41d3a8ec3f84213222bbc57cdfa37444 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Fri, 15 May 2026 22:04:20 +0000 Subject: [PATCH 08/11] fix(scripts): TS demo's independent P256 cross-check matches qos_p256 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@noble/curves/p256` verification at the end of the devnet demo was passing the raw 32-byte digest and getting back "verification FAILED". The mismatch is in how the two sides hash. `parser_app` computes `digest = sha256(borsh(payload))` then calls `P256Pair::sign(&digest)`. That forwards through `P256SignPair::sign` to `p256::ecdsa::SigningKey`'s default `Signer` impl, which applies SHA-256 to the input *again* before signing. So the actually-signed value is `sha256(digest)`, not `digest`. The TS cross-check now hashes one more time (`sha256(digest)`) before calling `p256.verify`, matching the on-the-wire signature. With this fix the demo prints `response signature verifies against pinned TVC pubkey ✓` after a real payai-settled devnet payment. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/x402-solana-devnet-demo.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/x402-solana-devnet-demo.ts b/scripts/x402-solana-devnet-demo.ts index 42621144..3066bc09 100644 --- a/scripts/x402-solana-devnet-demo.ts +++ b/scripts/x402-solana-devnet-demo.ts @@ -34,6 +34,7 @@ import { Connection, Keypair, VersionedTransaction } from "@solana/web3.js"; import { fileURLToPath } from "node:url"; import { readFile } from "node:fs/promises"; import { p256 } from "@noble/curves/p256"; +import { sha256 } from "@noble/hashes/sha2"; import { dirname, resolve } from "node:path"; import { createX402Client, type WalletAdapter } from "x402-solana/client"; @@ -151,8 +152,15 @@ async function main(): Promise { ); } // qos_p256 encodes P256Public as encrypt_public || sign_public, each - // SEC1 uncompressed (65 bytes = 130 hex). The sign half is the second - // 65 bytes; the message + sig are pre-hashed P256 ECDSA values. + // SEC1 uncompressed (65 bytes = 130 hex chars). The sign half is the + // second 65 bytes. + // + // Hashing: parser_app builds `digest = sha256(borsh(payload))` and calls + // `P256Pair::sign(&digest)`. P256SignPair::sign forwards to + // `p256::ecdsa::SigningKey::sign(msg)`, whose default `Signer` + // impl applies SHA-256 to `msg` again before signing. So the signed + // value is actually `sha256(digest)`. To verify with @noble/curves, + // hash one more time on this side and pass the prehash explicitly. const pubBytes = Buffer.from(sig.publicKey, "hex"); if (pubBytes.length !== 130) { throw new Error( @@ -160,9 +168,10 @@ async function main(): Promise { ); } const signHalf = pubBytes.subarray(65, 130); - const msgBytes = Buffer.from(sig.message, "hex"); + const digest = Buffer.from(sig.message, "hex"); + const inner = sha256(digest); const sigBytes = Buffer.from(sig.signature, "hex"); - const ok = p256.verify(sigBytes, msgBytes, signHalf, { prehash: false }); + const ok = p256.verify(sigBytes, inner, signHalf); if (!ok) { throw new Error("independent P256 verification FAILED"); } From 403decd48db3a8e97606e0d619b7e86305102ef1 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Fri, 15 May 2026 22:22:57 +0000 Subject: [PATCH 09/11] refactor(x402): /simplify pass on the recent x402 work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review across three reviewer agents (reuse / quality / efficiency). Net change: -641 LOC, no behavioral regression. All gateway lib tests (26), mock-facilitator unit tests (4), and integration paths 1-6 still green. - Delete `src/integration/src/solana_x402_client.rs` (-253 LOC) and `tests/x402_payai_devnet_test.rs` (-313 LOC). The hand-rolled Solana x402 client was a partial, subtly-wrong reimplementation of `x402-chain-solana::v1_solana_exact::build_signed_transfer_transaction` (it used legacy `transfer` instead of `transfer_checked`, ignored the facilitator's `extra.feePayer`, and skipped the random memo + compute budget instructions payai actually requires). The TS demo and `docs/x402-devnet-playbook.md` are the canonical way to drive real devnet payments; the Rust client added no coverage the TS demo doesn't already give us. Drops solana-sdk, solana-client, spl-token, spl-associated-token-account, bincode, thiserror from the integration crate's dev-deps. - `AttestationVerifier`: drop the redundant `pinned_hex_lower: String` field; hex-decode the pinned pubkey once at construction and store the raw bytes, then `subtle::ConstantTimeEq` on decoded bytes per request. Removes a per-request `to_ascii_lowercase` allocation and makes hex-case-insensitivity a free property of decoding rather than a separate code path. Added a test that pins an uppercase hex and verifies a lowercase response. - `AttestationVerifier`: delete the unused `require_verifier()` and `from_file()` public functions (dead surface; `from_lookup` reads files via `X402_TVC_VERIFIER_PUBKEY_FILE` already). Delete the `MissingSignature` error variant that was never returned. Pubkey is now logged with ASCII `..` rather than Unicode `…`. - `handlers/parse.rs`: missing-signature-from-backend now returns 502 (was 500). That's the same class of failure as an invalid signature — both should make x402-axum's settle-on-success contract skip `/settle`, so payment is never charged for an unattested or absent response. - `x402_gateway_test.rs`: collapse `start_procs()` + the new `start_procs_with_env(extra)` into a single `start_procs(extra_env)`. The bare-environment call sites get `&[]`. - `mock-facilitator`: drop the unused `State` extractor on `verify()` (it never touched the state). Switch `settle_count` fetch_add / load to `Ordering::Relaxed` (no other state was being synchronized via this counter; SeqCst was just superstition). - `scripts/x402-solana-devnet-demo.ts`: replace Unicode box-drawing `──` in the section banners with ASCII `--`, per the repo-wide ASCII-only convention. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/x402-solana-devnet-demo.ts | 2 +- src/Cargo.lock | 6 - src/integration/Cargo.toml | 7 - src/integration/src/solana_x402_client.rs | 253 -------------- src/integration/tests/x402_gateway_test.rs | 20 +- .../tests/x402_payai_devnet_test.rs | 313 ------------------ src/parser/gateway/src/attestation.rs | 109 ++---- src/parser/gateway/src/handlers/parse.rs | 5 +- src/parser/gateway/src/main.rs | 9 +- src/parser/mock-facilitator/src/lib.rs | 13 +- 10 files changed, 48 insertions(+), 689 deletions(-) delete mode 100644 src/integration/src/solana_x402_client.rs delete mode 100644 src/integration/tests/x402_payai_devnet_test.rs diff --git a/scripts/x402-solana-devnet-demo.ts b/scripts/x402-solana-devnet-demo.ts index 3066bc09..7408ea41 100644 --- a/scripts/x402-solana-devnet-demo.ts +++ b/scripts/x402-solana-devnet-demo.ts @@ -70,7 +70,7 @@ function buildWalletAdapter(buyer: Keypair): WalletAdapter { function logSection(title: string): void { console.log(""); - console.log(`── ${title} `.padEnd(72, "─")); + console.log(`-- ${title} `.padEnd(72, "-")); } async function main(): Promise { diff --git a/src/Cargo.lock b/src/Cargo.lock index 3017bc2e..8db2db77 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -4768,7 +4768,6 @@ name = "integration" version = "0.1.0" dependencies = [ "base64 0.22.1", - "bincode", "borsh 1.6.0", "bs58 0.5.1", "chrono", @@ -4793,11 +4792,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "solana-client", - "solana-sdk", - "spl-associated-token-account 6.0.0", - "spl-token 7.0.0", - "thiserror 1.0.69", "tokio", "tonic 0.9.2", "tracing", diff --git a/src/integration/Cargo.toml b/src/integration/Cargo.toml index 952ae8b2..8334de51 100644 --- a/src/integration/Cargo.toml +++ b/src/integration/Cargo.toml @@ -56,13 +56,6 @@ tracing = { workspace = true } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "net", "time"] } -# Test-only Solana x402 client (no production crate depends on these). -solana-sdk = "2.1.15" -solana-client = "2.1.15" -spl-token = "7.0.0" -spl-associated-token-account = "6.0.0" -bincode = "1.3" -thiserror = "1" [lints] workspace = true diff --git a/src/integration/src/solana_x402_client.rs b/src/integration/src/solana_x402_client.rs deleted file mode 100644 index 00a00730..00000000 --- a/src/integration/src/solana_x402_client.rs +++ /dev/null @@ -1,253 +0,0 @@ -//! Minimal test-only x402 Solana client. -//! -//! Builds an `X-PAYMENT` header for the v2 `exact` scheme on Solana, given a -//! `Payment-Required` challenge from a gated endpoint. The client signs a -//! `VersionedTransaction` that transfers USDC from the buyer's ATA to the -//! seller's ATA, leaves the fee-payer slot empty for the facilitator to fill -//! at `/settle`, and packages the partially-signed transaction in the wire -//! format described in -//! `coinbase/x402/specs/schemes/exact/scheme_exact_svm.md`. -//! -//! This client only ships in the integration test crate. We deliberately do -//! NOT publish a production Rust x402 Solana client — payai's `x402-solana` -//! npm package is the supported reference implementation; this Rust version -//! exists solely so `cargo test` can exercise the gateway's wire format end -//! to end on devnet without a Node dependency. - -use base64::Engine; -use serde::{Deserialize, Serialize}; -use solana_client::rpc_client::RpcClient; -use solana_sdk::commitment_config::CommitmentConfig; -use solana_sdk::hash::Hash; -use solana_sdk::message::{Message, VersionedMessage}; -use solana_sdk::pubkey::Pubkey; -use solana_sdk::signature::{Keypair, SeedDerivable, Signer}; -use solana_sdk::transaction::VersionedTransaction; -use spl_associated_token_account::get_associated_token_address; -use std::str::FromStr; - -/// Subset of the `PaymentRequirements` challenge body the gateway emits in -/// the `Payment-Required` header. We only need the fields below. -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] // some fields ride along for visibility / future use -pub struct PaymentRequirementsLite { - pub scheme: String, - pub network: String, - pub amount: String, - pub asset: String, - #[serde(rename = "payTo")] - pub pay_to: String, - #[serde(default)] - pub extra: Option, - /// Full original JSON so we can echo it back in the `accepted` field of - /// the payment payload exactly as the server offered it. - #[serde(skip)] - pub raw: serde_json::Value, -} - -impl PaymentRequirementsLite { - pub fn from_value(v: &serde_json::Value) -> Result { - let mut parsed: Self = serde_json::from_value(v.clone()) - .map_err(|e| ClientError::BadChallenge(format!("parse PaymentRequirements: {e}")))?; - parsed.raw = v.clone(); - if parsed.scheme != "exact" { - return Err(ClientError::BadChallenge(format!( - "unsupported scheme '{}', only 'exact' is supported", - parsed.scheme - ))); - } - Ok(parsed) - } - - /// Devnet vs mainnet routing for the buyer's RPC. The challenge advertises - /// `solana-devnet` (v1) or `solana:EtWTRAB…` (CAIP-2) — both map to - /// devnet. - pub fn is_devnet(&self) -> bool { - self.network == "solana-devnet" || self.network.starts_with("solana:EtWTRAB") - } -} - -#[derive(Debug, thiserror::Error)] -pub enum ClientError { - #[error("malformed challenge: {0}")] - BadChallenge(String), - #[error("bad pubkey: {0}")] - BadPubkey(String), - #[error("rpc error: {0}")] - Rpc(String), - #[error("serialization error: {0}")] - Serialize(String), - #[error("amount parse error: {0}")] - Amount(String), -} - -/// Outer wire shape for the `X-PAYMENT` header, per x402 v2 SVM exact. -#[derive(Debug, Serialize)] -struct PaymentPayload<'a> { - #[serde(rename = "x402Version")] - x402_version: u32, - scheme: &'a str, - network: &'a str, - payload: SvmPayload, - /// We echo the original challenge here so the server can compare what we - /// agreed to with what it offered. Some facilitators ignore it. - accepted: &'a serde_json::Value, -} - -#[derive(Debug, Serialize)] -struct SvmPayload { - /// Base64 of the buyer-partially-signed `VersionedTransaction` bytes. - transaction: String, -} - -/// Build a buyer-signed `VersionedTransaction` carrying an SPL token transfer -/// from the buyer's ATA to the seller's ATA, with the fee-payer left for the -/// facilitator to fill in at `/settle`. -pub fn build_payment_transaction( - rpc: &RpcClient, - buyer: &Keypair, - requirements: &PaymentRequirementsLite, -) -> Result { - let usdc_mint = - Pubkey::from_str(&requirements.asset).map_err(|e| ClientError::BadPubkey(e.to_string()))?; - let seller = Pubkey::from_str(&requirements.pay_to) - .map_err(|e| ClientError::BadPubkey(e.to_string()))?; - let amount: u64 = requirements - .amount - .parse() - .map_err(|e| ClientError::Amount(format!("{e}")))?; - - let buyer_pk = buyer.pubkey(); - let buyer_ata = get_associated_token_address(&buyer_pk, &usdc_mint); - let seller_ata = get_associated_token_address(&seller, &usdc_mint); - - let mut instructions = vec![]; - - // Best-effort: if the seller's ATA doesn't exist, include a create-idempotent - // instruction up front. The facilitator (as fee payer) pays the rent. - instructions.push( - spl_associated_token_account::instruction::create_associated_token_account_idempotent( - &Pubkey::default(), // placeholder: facilitator replaces fee-payer - &seller, - &usdc_mint, - &spl_token::id(), - ), - ); - - // SPL Token v1 transfer instruction. v2 (Token-2022) requires - // `transfer_checked` and additional account metas; we keep v1 since payai - // accepts both via the scheme_exact_svm spec. - instructions.push( - spl_token::instruction::transfer( - &spl_token::id(), - &buyer_ata, - &seller_ata, - &buyer_pk, - &[&buyer_pk], - amount, - ) - .map_err(|e| ClientError::Serialize(format!("transfer ix: {e}")))?, - ); - - // Use the buyer as a temporary fee-payer placeholder. The wire format keeps - // the buyer's signature slot at index 0 and leaves index N for the - // facilitator; payai re-anchors fee-payer at /settle. Reading the spec - // (`scheme_exact_svm.md`) more closely is required if we ever sign for a - // strict fee-payer-as-feePayer position — for tests against payai this - // shape works because payai replaces the message header's - // num_required_signatures = 1 slot. - let recent_blockhash: Hash = rpc - .get_latest_blockhash_with_commitment(CommitmentConfig::confirmed()) - .map_err(|e| ClientError::Rpc(e.to_string()))? - .0; - - let message = Message::new_with_blockhash(&instructions, Some(&buyer_pk), &recent_blockhash); - let versioned = VersionedMessage::Legacy(message); - let tx = VersionedTransaction::try_new(versioned, &[buyer]) - .map_err(|e| ClientError::Serialize(format!("sign tx: {e}")))?; - - Ok(tx) -} - -/// Serialize the buyer-signed transaction and wrap it in the v2 `X-PAYMENT` -/// header value. -pub fn build_x_payment_header( - challenge: &PaymentRequirementsLite, - tx: &VersionedTransaction, -) -> Result { - let tx_bytes = bincode::serialize(tx).map_err(|e| ClientError::Serialize(e.to_string()))?; - let tx_b64 = base64::engine::general_purpose::STANDARD.encode(&tx_bytes); - - let payload = PaymentPayload { - x402_version: 2, - scheme: &challenge.scheme, - network: &challenge.network, - payload: SvmPayload { - transaction: tx_b64, - }, - accepted: &challenge.raw, - }; - let json = - serde_json::to_string(&payload).map_err(|e| ClientError::Serialize(e.to_string()))?; - Ok(base64::engine::general_purpose::STANDARD.encode(json)) -} - -/// Load the reproducible devnet keypair from `fixtures/devnet/wallet.seed`. -/// Trims surrounding whitespace; expects exactly 32 bytes after trimming. -pub fn load_devnet_keypair(path: &str) -> Result { - let raw = std::fs::read_to_string(path) - .map_err(|e| ClientError::BadChallenge(format!("read {path}: {e}")))?; - let trimmed = raw.trim().as_bytes(); - if trimmed.len() != 32 { - return Err(ClientError::BadChallenge(format!( - "expected 32-byte seed in {path}, got {} bytes", - trimmed.len() - ))); - } - Keypair::from_seed(trimmed) - .map_err(|e| ClientError::BadChallenge(format!("derive keypair: {e}"))) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - - #[test] - fn requirements_parses_devnet_challenge() { - let v = serde_json::json!({ - "scheme": "exact", - "network": "solana-devnet", - "amount": "1000", - "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", - "payTo": "EGBQqKn968sVv5cQh5Cr72pSTHfxsuzq7o7asqYB5uEV", - "extra": { "feePayer": "PayAiFacilitator11111111111111111111111111" } - }); - let r = PaymentRequirementsLite::from_value(&v).unwrap(); - assert!(r.is_devnet()); - assert_eq!(r.scheme, "exact"); - assert_eq!(r.amount, "1000"); - } - - #[test] - fn requirements_rejects_unsupported_scheme() { - let v = serde_json::json!({ - "scheme": "upto", - "network": "solana-devnet", - "amount": "1000", - "asset": "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", - "payTo": "EGBQqKn968sVv5cQh5Cr72pSTHfxsuzq7o7asqYB5uEV" - }); - assert!(PaymentRequirementsLite::from_value(&v).is_err()); - } - - #[test] - fn load_devnet_keypair_round_trips() { - let kp = load_devnet_keypair("fixtures/devnet/wallet.seed").expect("load fixture keypair"); - // The address is deterministic. Print it so the test logs document - // the funded fixture address. Format-check only. - let addr = kp.pubkey().to_string(); - assert!(!addr.is_empty()); - eprintln!("[fixture] devnet buyer address: {addr}"); - } -} diff --git a/src/integration/tests/x402_gateway_test.rs b/src/integration/tests/x402_gateway_test.rs index 4e6b2b49..d3ad3881 100644 --- a/src/integration/tests/x402_gateway_test.rs +++ b/src/integration/tests/x402_gateway_test.rs @@ -82,11 +82,7 @@ fn fixture_ephemeral_pubkey_hex() -> String { qos_hex::encode(&pair.public_key().to_bytes()) } -async fn start_procs() -> Procs { - start_procs_with_env(&[]).await -} - -async fn start_procs_with_env(extra: &[(&str, &str)]) -> Procs { +async fn start_procs(extra_env: &[(&str, &str)]) -> Procs { // --- Friction 2: startup ordering --- // parser_gateway probes mock_facilitator at startup. We must ensure // mock_facilitator is ready before spawning the gateway. @@ -137,7 +133,7 @@ async fn start_procs_with_env(extra: &[(&str, &str)]) -> Procs { "X402_FACILITATOR_URL", format!("http://127.0.0.1:{MOCK_PORT}"), ); - for (k, v) in extra { + for (k, v) in extra_env { cmd.env(k, v); } let gateway = cmd @@ -247,7 +243,7 @@ const ETH_TX_HEX: &str = "0xf86c808504a817c8008252089435353535353535353535353535 #[tokio::test] async fn path1_v2_without_payment_returns_402() { let _guard = TEST_MUTEX.lock().await; - let _p = start_procs().await; + let _p = start_procs(&[]).await; let body = serde_json::json!({ "request": { "unsigned_payload": "0xdeadbeef", "chain": "CHAIN_ETHEREUM" } @@ -303,7 +299,7 @@ async fn path1_v2_without_payment_returns_402() { #[tokio::test] async fn path2_v2_with_valid_payment_returns_200() { let _guard = TEST_MUTEX.lock().await; - let _p = start_procs().await; + let _p = start_procs(&[]).await; // Fetch actual requirements from the 402 response. let requirements = fetch_v2_requirements().await; @@ -339,7 +335,7 @@ async fn path2_v2_with_valid_payment_returns_200() { #[tokio::test] async fn path3_v2_valid_payment_bad_tx_returns_400() { let _guard = TEST_MUTEX.lock().await; - let _p = start_procs().await; + let _p = start_procs(&[]).await; let requirements = fetch_v2_requirements().await; let payment_header = build_payment_signature(&requirements); @@ -369,7 +365,7 @@ async fn path3_v2_valid_payment_bad_tx_returns_400() { #[tokio::test] async fn path4_v1_without_payment_returns_200() { let _guard = TEST_MUTEX.lock().await; - let _p = start_procs().await; + let _p = start_procs(&[]).await; let body = serde_json::json!({ "request": { "unsigned_payload": ETH_TX_HEX, "chain": "CHAIN_ETHEREUM" } @@ -392,7 +388,7 @@ async fn path4_v1_without_payment_returns_200() { #[tokio::test] async fn path5_health_open() { let _guard = TEST_MUTEX.lock().await; - let _p = start_procs().await; + let _p = start_procs(&[]).await; let resp = reqwest::get(format!("http://127.0.0.1:{GW_PORT}/health")) .await @@ -422,7 +418,7 @@ async fn path6_tampered_pubkey_returns_502_no_settle() { // Sanity: must differ from the fixture's pubkey. assert_ne!(wrong_hex, fixture_ephemeral_pubkey_hex()); - let _p = start_procs_with_env(&[("X402_TVC_VERIFIER_PUBKEY_HEX", wrong_hex.as_str())]).await; + let _p = start_procs(&[("X402_TVC_VERIFIER_PUBKEY_HEX", wrong_hex.as_str())]).await; // Read settle_count before the request. let before = read_settle_count().await; diff --git a/src/integration/tests/x402_payai_devnet_test.rs b/src/integration/tests/x402_payai_devnet_test.rs deleted file mode 100644 index b1bf5863..00000000 --- a/src/integration/tests/x402_payai_devnet_test.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! End-to-end x402 gating against the **real** payai facilitator on Solana -//! **devnet**. Gated by `#[ignore]` AND `X402_E2E=1` so it stays out of the -//! default `cargo test` run. -//! -//! Run with: -//! ```sh -//! X402_E2E=1 cargo test -p integration --test x402_payai_devnet_test -- --ignored --nocapture -//! ``` -//! -//! Requirements: -//! - Network egress to `https://facilitator.payai.network` + Solana devnet RPC. -//! - The committed fixture wallet (derived from -//! `src/integration/fixtures/devnet/wallet.seed`) must be funded with at -//! least `MIN_DEVNET_SOL` SOL and `MIN_DEVNET_USDC` USDC on devnet. Faucets: -//! and . -//! - The gateway is built locally (`make -C src build` first). - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -#[path = "../src/solana_x402_client.rs"] -mod solana_x402_client; - -use base64::Engine; -use qos_p256::P256Pair; -use solana_client::rpc_client::RpcClient; -use solana_sdk::commitment_config::CommitmentConfig; -use solana_sdk::signer::Signer; -use std::net::TcpListener; -use std::process::{Child, Command, Stdio}; -use std::time::Duration; -use tokio::sync::Mutex; -use tokio::time::sleep; - -use solana_x402_client::{ - PaymentRequirementsLite, build_payment_transaction, build_x_payment_header, load_devnet_keypair, -}; - -// Lock with the existing x402_gateway_test paths — they share ports 18080 / -// 18090 / 44020 and both touch the parser_grpc_server. -static TEST_MUTEX: Mutex<()> = Mutex::const_new(()); - -const GW_PORT: u16 = 18180; -const PAYAI_FACILITATOR: &str = "https://facilitator.payai.network"; -const DEVNET_RPC: &str = "https://api.devnet.solana.com"; -const MIN_DEVNET_SOL_LAMPORTS: u64 = 50_000_000; // 0.05 SOL -const MIN_DEVNET_USDC_ATOMIC: u64 = 1_000_000; // 1.00 USDC -const PARSER_PRICE_USD: &str = "0.001"; // matches X402_NETWORK=solana-devnet default -const WALLET_SEED_PATH: &str = "fixtures/devnet/wallet.seed"; - -fn target_bin(name: &str) -> String { - format!("../target/debug/{name}") -} - -struct Procs { - grpc: Child, - gateway: Child, -} - -impl Drop for Procs { - fn drop(&mut self) { - let _ = self.grpc.kill(); - let _ = self.gateway.kill(); - let _ = self.grpc.wait(); - let _ = self.gateway.wait(); - } -} - -async fn wait_until_port_free(port: u16) { - for _ in 0..100 { - if TcpListener::bind(("127.0.0.1", port)).is_ok() { - return; - } - sleep(Duration::from_millis(50)).await; - } -} - -async fn wait_ready(url: &str) { - let client = reqwest::Client::new(); - for _ in 0..200 { - if let Ok(r) = client.get(url).send().await { - if r.status().is_success() { - return; - } - } - sleep(Duration::from_millis(100)).await; - } - panic!("service at {url} never became ready (timed out after 20 s)"); -} - -async fn start_stack(receiver_b58: &str, tvc_pubkey_hex: &str) -> Procs { - wait_until_port_free(44020).await; - wait_until_port_free(GW_PORT).await; - - let grpc = Command::new(target_bin("parser_grpc_server")) - .env("EPHEMERAL_FILE", "fixtures/ephemeral.secret") - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .expect("spawn parser_grpc_server"); - - let gateway = Command::new(target_bin("parser_gateway")) - .env("GATEWAY_PORT", GW_PORT.to_string()) - .env("GRPC_ADDR", "http://127.0.0.1:44020") - .env("X402_PROFILE", "payai") - .env("X402_FACILITATOR_URL", PAYAI_FACILITATOR) - .env("X402_NETWORK", "solana-devnet") - .env("X402_PAYTO", receiver_b58) - .env("X402_TVC_VERIFIER_PUBKEY_HEX", tvc_pubkey_hex) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - .expect("spawn parser_gateway"); - - wait_ready(&format!("http://127.0.0.1:{GW_PORT}/health")).await; - Procs { grpc, gateway } -} - -fn fixture_ephemeral_pubkey_hex() -> String { - let pair = P256Pair::from_hex_file("fixtures/ephemeral.secret") - .expect("load fixtures/ephemeral.secret"); - qos_hex::encode(&pair.public_key().to_bytes()) -} - -fn skip_unless_e2e() -> bool { - if std::env::var("X402_E2E").as_deref().unwrap_or("") != "1" { - eprintln!("skip: X402_E2E=1 not set (see test module docs for prerequisites)"); - return true; - } - false -} - -fn assert_wallet_funded(rpc: &RpcClient, buyer_pk: &solana_sdk::pubkey::Pubkey) { - let sol_lamports = rpc - .get_balance_with_commitment(buyer_pk, CommitmentConfig::confirmed()) - .expect("query SOL balance") - .value; - if sol_lamports < MIN_DEVNET_SOL_LAMPORTS { - // Best-effort airdrop. Faucet often rate-limits; we panic with - // instructions if it doesn't grant enough. - let _ = rpc.request_airdrop(buyer_pk, MIN_DEVNET_SOL_LAMPORTS * 2); - std::thread::sleep(Duration::from_secs(8)); - let after = rpc - .get_balance_with_commitment(buyer_pk, CommitmentConfig::confirmed()) - .expect("re-query SOL balance") - .value; - if after < MIN_DEVNET_SOL_LAMPORTS { - panic!( - "buyer wallet {buyer_pk} has only {after} lamports on devnet; \ - fund it via https://faucet.solana.com or `solana airdrop` and retry" - ); - } - } - - // Circle devnet USDC. - let usdc_mint = - solana_sdk::pubkey::Pubkey::try_from("4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU") - .expect("devnet USDC mint pubkey"); - let buyer_ata = - spl_associated_token_account::get_associated_token_address(buyer_pk, &usdc_mint); - match rpc.get_token_account_balance(&buyer_ata) { - Ok(bal) => { - let amount: u64 = bal.amount.parse().expect("token amount must parse as u64"); - if amount < MIN_DEVNET_USDC_ATOMIC { - panic!( - "buyer ATA {buyer_ata} has only {amount} USDC atoms on devnet \ - (need {MIN_DEVNET_USDC_ATOMIC}); fund via https://faucet.circle.com" - ); - } - } - Err(e) => { - panic!( - "buyer ATA {buyer_ata} for {buyer_pk} does not exist or is unreadable on \ - devnet: {e}; fund via https://faucet.circle.com" - ); - } - } -} - -async fn fetch_v2_challenge() -> serde_json::Value { - let body = serde_json::json!({ - "request": { "unsigned_payload": "0xdeadbeef", "chain": "CHAIN_ETHEREUM" } - }); - let resp = reqwest::Client::new() - .post(format!( - "http://127.0.0.1:{GW_PORT}/visualsign/api/v2/parse" - )) - .json(&body) - .send() - .await - .expect("send probe"); - assert_eq!(resp.status(), 402, "expected 402 for probe"); - let header = resp - .headers() - .get("Payment-Required") - .expect("Payment-Required header") - .to_str() - .unwrap() - .to_string(); - let decoded = base64::engine::general_purpose::STANDARD - .decode(header.as_bytes()) - .expect("base64 Payment-Required"); - let parsed: serde_json::Value = - serde_json::from_slice(&decoded).expect("Payment-Required JSON"); - parsed -} - -/// Path 7a: gateway boots with payai + solana-devnet and serves a 402 whose -/// challenge advertises `solana-devnet`. No payment is performed here; this -/// path is cheap and lets us validate the boot path without spending USDC. -#[tokio::test] -#[ignore] -async fn path7a_gateway_boots_with_payai_devnet() { - if skip_unless_e2e() { - return; - } - let _guard = TEST_MUTEX.lock().await; - - let buyer = load_devnet_keypair(WALLET_SEED_PATH).expect("load fixture keypair"); - let receiver = buyer.pubkey(); // self-receive is fine for this probe - let tvc_hex = fixture_ephemeral_pubkey_hex(); - - let _p = start_stack(&receiver.to_string(), &tvc_hex).await; - - let challenge = fetch_v2_challenge().await; - let accepts = challenge["accepts"].as_array().expect("accepts array"); - let has_devnet = accepts - .iter() - .any(|t| t["network"].as_str() == Some("solana-devnet")); - assert!( - has_devnet, - "expected solana-devnet in 402 accepts; got: {accepts:?}" - ); -} - -/// Path 7b: full pay → parse → verify cycle against real payai facilitator -/// and real Solana devnet USDC. Spends `PARSER_PRICE_USD` from the fixture -/// wallet. -#[tokio::test] -#[ignore] -async fn path7b_full_devnet_pay_and_verify() { - if skip_unless_e2e() { - return; - } - let _guard = TEST_MUTEX.lock().await; - - let buyer = load_devnet_keypair(WALLET_SEED_PATH).expect("load fixture keypair"); - let buyer_pk = buyer.pubkey(); - eprintln!("[fixture] devnet buyer address: {buyer_pk}"); - - let rpc = RpcClient::new_with_commitment(DEVNET_RPC.to_string(), CommitmentConfig::confirmed()); - assert_wallet_funded(&rpc, &buyer_pk); - - let receiver = buyer_pk; // self-transfer keeps test self-contained - let tvc_hex = fixture_ephemeral_pubkey_hex(); - let _p = start_stack(&receiver.to_string(), &tvc_hex).await; - - let challenge = fetch_v2_challenge().await; - let accepts = challenge["accepts"] - .as_array() - .expect("accepts must be array"); - let devnet = accepts - .iter() - .find(|t| t["network"].as_str() == Some("solana-devnet")) - .expect("solana-devnet entry in accepts") - .clone(); - - let reqs = PaymentRequirementsLite::from_value(&devnet).expect("parse devnet challenge"); - let tx = build_payment_transaction(&rpc, &buyer, &reqs).expect("build payment tx"); - let header_value = build_x_payment_header(&reqs, &tx).expect("build X-PAYMENT header"); - - let eth_tx_hex = "0xf86c808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"; - - let body = serde_json::json!({ - "request": { "unsigned_payload": eth_tx_hex, "chain": "CHAIN_ETHEREUM" } - }); - let resp = reqwest::Client::new() - .post(format!( - "http://127.0.0.1:{GW_PORT}/visualsign/api/v2/parse" - )) - .header("X-PAYMENT", header_value) - .json(&body) - .send() - .await - .expect("send paid request"); - let status = resp.status(); - let x_payment_response = resp - .headers() - .get("X-PAYMENT-RESPONSE") - .map(|v| v.to_str().unwrap_or_default().to_string()); - let body_text = resp.text().await.unwrap_or_default(); - assert_eq!( - status, 200, - "expected 200 after paying; body: {body_text}; price ${PARSER_PRICE_USD}" - ); - assert!( - x_payment_response.is_some(), - "expected X-PAYMENT-RESPONSE header on 200" - ); - - // Cross-check: the gateway's own pinned verifier already passed. We also - // verify here in the test that the response's `signature.publicKey` equals - // the pinned hex, so this test would catch any regression in the gateway's - // attestation wiring. - let v: serde_json::Value = serde_json::from_str(&body_text).expect("response JSON"); - let response_pubkey = v["response"]["parsedTransaction"]["signature"]["publicKey"] - .as_str() - .expect("response.signature.publicKey must be present"); - assert_eq!( - response_pubkey.to_ascii_lowercase(), - tvc_hex.to_ascii_lowercase(), - "response pubkey must match pinned TVC verifier" - ); -} diff --git a/src/parser/gateway/src/attestation.rs b/src/parser/gateway/src/attestation.rs index 940a079a..370d8bc8 100644 --- a/src/parser/gateway/src/attestation.rs +++ b/src/parser/gateway/src/attestation.rs @@ -13,13 +13,10 @@ use generated::parser::{Signature, SignatureScheme}; use qos_p256::P256Public; -use std::path::Path; use subtle::ConstantTimeEq; #[derive(Debug, thiserror::Error)] pub enum AttestationError { - #[error("missing signature on parse response")] - MissingSignature, #[error("unsupported signature scheme: {0}")] UnsupportedScheme(String), #[error("public key mismatch: response key does not match pinned TVC verifier key")] @@ -39,7 +36,7 @@ pub enum AttestationError { pub struct AttestationVerifier { pinned_public: P256Public, - pinned_hex_lower: String, + pinned_bytes: Vec, } impl AttestationVerifier { @@ -78,16 +75,16 @@ impl AttestationVerifier { } pub fn from_hex(hex_value: &str) -> Result { - let trimmed = hex_value.trim(); - let bytes = qos_hex::decode(trimmed).map_err(|e| AttestationError::Hex { - field: "X402_TVC_VERIFIER_PUBKEY_HEX", - message: format!("{e:?}"), - })?; - let pinned_public = P256Public::from_bytes(&bytes) + let pinned_bytes = + qos_hex::decode(hex_value.trim()).map_err(|e| AttestationError::Hex { + field: "X402_TVC_VERIFIER_PUBKEY_HEX", + message: format!("{e:?}"), + })?; + let pinned_public = P256Public::from_bytes(&pinned_bytes) .map_err(|e| AttestationError::InvalidPinnedKey(format!("{e:?}")))?; Ok(Self { pinned_public, - pinned_hex_lower: trimmed.to_ascii_lowercase(), + pinned_bytes, }) } @@ -101,11 +98,16 @@ impl AttestationVerifier { return Err(AttestationError::UnsupportedScheme(scheme_name)); } - let response_hex_lower = sig.public_key.to_ascii_lowercase(); - let pinned_bytes = self.pinned_hex_lower.as_bytes(); - let response_bytes = response_hex_lower.as_bytes(); - if pinned_bytes.len() != response_bytes.len() - || pinned_bytes.ct_eq(response_bytes).unwrap_u8() != 1 + let response_bytes = + qos_hex::decode(&sig.public_key).map_err(|e| AttestationError::Hex { + field: "signature.public_key", + message: format!("{e:?}"), + })?; + if response_bytes.len() != self.pinned_bytes.len() + || response_bytes + .ct_eq(self.pinned_bytes.as_slice()) + .unwrap_u8() + != 1 { return Err(AttestationError::PubkeyMismatch); } @@ -125,37 +127,12 @@ impl AttestationVerifier { .map_err(|_| AttestationError::Verify) } - /// Public hex representation of the pinned key, lowercased. Useful for - /// log/error messages. - pub fn pinned_hex(&self) -> &str { - &self.pinned_hex_lower + /// Hex representation of the pinned key. Useful for log/error messages. + pub fn pinned_hex(&self) -> String { + qos_hex::encode(&self.pinned_bytes) } } -/// Allow callers to fail closed when a pinned verifier is required but absent. -pub fn require_verifier( - profile_is_local: bool, - verifier: Option, -) -> Result, AttestationError> { - match (verifier, profile_is_local) { - (Some(v), _) => Ok(Some(v)), - (None, true) => Ok(None), - (None, false) => Err(AttestationError::InvalidPinnedKey( - "X402_TVC_VERIFIER_PUBKEY_HEX or _FILE required for non-local profile".into(), - )), - } -} - -/// Borrow the path-only helper into a non-allocating verifier of the file. -/// Provided for callers that prefer passing a `&Path` over going through env vars. -pub fn from_file(path: &Path) -> Result { - let raw = std::fs::read_to_string(path).map_err(|e| AttestationError::PubkeyFile { - path: path.display().to_string(), - message: e.to_string(), - })?; - AttestationVerifier::from_hex(raw.trim()) -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { @@ -205,10 +182,7 @@ mod tests { let pair_b = P256Pair::generate().unwrap(); let pinned_hex = qos_hex::encode(&pair_a.public_key().to_bytes()); let verifier = AttestationVerifier::from_hex(&pinned_hex).unwrap(); - let mut sig = make_signed_response(&pair_b); - // Even if we lied about the public key, the mismatch with the pinned - // hex should be caught first. - sig.public_key = qos_hex::encode(&pair_b.public_key().to_bytes()); + let sig = make_signed_response(&pair_b); assert!(matches!( verifier.verify(&sig).unwrap_err(), AttestationError::PubkeyMismatch @@ -221,7 +195,6 @@ mod tests { let pinned_hex = qos_hex::encode(&pair.public_key().to_bytes()); let verifier = AttestationVerifier::from_hex(&pinned_hex).unwrap(); let mut sig = make_signed_response(&pair); - // flip the last hex char of the signature let mut chars: Vec = sig.signature.chars().collect(); let last_idx = chars.len() - 1; chars[last_idx] = if chars[last_idx] == '0' { '1' } else { '0' }; @@ -246,41 +219,11 @@ mod tests { } #[test] - fn require_verifier_fails_closed_in_non_local() { - let res = require_verifier(false, None); - assert!(res.is_err()); - } - - #[test] - fn require_verifier_allows_missing_in_local() { - let res = require_verifier(true, None).unwrap(); - assert!(res.is_none()); - } - - #[test] - fn from_lookup_file_path_works() { + fn pubkey_compare_is_case_insensitive() { let pair = P256Pair::generate().unwrap(); let pinned_hex = qos_hex::encode(&pair.public_key().to_bytes()); - let tmp = tempfile_path("tvc_pubkey"); - std::fs::write(&tmp, &pinned_hex).unwrap(); - let v = AttestationVerifier::from_lookup(|k| match k { - "X402_TVC_VERIFIER_PUBKEY_FILE" => Some(tmp.display().to_string()), - _ => None, - }) - .unwrap() - .unwrap(); - assert_eq!(v.pinned_hex(), pinned_hex.to_ascii_lowercase()); - let _ = std::fs::remove_file(&tmp); - } - - fn tempfile_path(prefix: &str) -> std::path::PathBuf { - let mut p = std::env::temp_dir(); - let pid = std::process::id(); - let suffix = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - p.push(format!("{prefix}-{pid}-{suffix}")); - p + let verifier = AttestationVerifier::from_hex(&pinned_hex.to_uppercase()).unwrap(); + let sig = make_signed_response(&pair); + verifier.verify(&sig).expect("hex case must not matter"); } } diff --git a/src/parser/gateway/src/handlers/parse.rs b/src/parser/gateway/src/handlers/parse.rs index efc152b4..452d0723 100644 --- a/src/parser/gateway/src/handlers/parse.rs +++ b/src/parser/gateway/src/handlers/parse.rs @@ -87,11 +87,14 @@ pub async fn parse_handler( } }; + // Missing signature from parser_app is the same class of trust failure + // as a bad signature: surface 502 + don't settle. (502 makes x402-axum's + // settle-on-success contract treat this as "do not charge".) let proto_signature = match parsed_tx.signature { Some(s) => s, None => { return ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::BAD_GATEWAY, Json(error_response("missing signature in response".to_string())), ); } diff --git a/src/parser/gateway/src/main.rs b/src/parser/gateway/src/main.rs index b0419c1e..46d72a4c 100644 --- a/src/parser/gateway/src/main.rs +++ b/src/parser/gateway/src/main.rs @@ -47,11 +47,10 @@ async fn main() -> Result<(), Box> { let attestation: Option> = match AttestationVerifier::from_env() { Ok(Some(v)) => { - println!( - "x402 attestation: pinned TVC pubkey {}…{} (lower-cased)", - &v.pinned_hex()[..8.min(v.pinned_hex().len())], - &v.pinned_hex()[v.pinned_hex().len().saturating_sub(8)..] - ); + let hex = v.pinned_hex(); + let head = &hex[..8.min(hex.len())]; + let tail = &hex[hex.len().saturating_sub(8)..]; + println!("x402 attestation: pinned TVC pubkey {head}..{tail}"); Some(Arc::new(v)) } Ok(None) => { diff --git a/src/parser/mock-facilitator/src/lib.rs b/src/parser/mock-facilitator/src/lib.rs index 3747d308..0a8af6c2 100644 --- a/src/parser/mock-facilitator/src/lib.rs +++ b/src/parser/mock-facilitator/src/lib.rs @@ -92,10 +92,7 @@ fn extract_network(req: &Value) -> String { .to_string() } -async fn verify( - State(_state): State, - Json(req): Json, -) -> Json { +async fn verify(Json(req): Json) -> Json { Json(VerifyResponse { is_valid: true, payer: extract_payer(&req.payment_payload), @@ -110,7 +107,7 @@ async fn settle( let mut buf = [0u8; 32]; rand::thread_rng().fill_bytes(&mut buf); let tx = format!("0xmock{}", hex_encode(&buf)); - state.settle_count.fetch_add(1, Ordering::SeqCst); + state.settle_count.fetch_add(1, Ordering::Relaxed); Json(SettleResponse { success: true, transaction: tx, @@ -147,7 +144,7 @@ async fn supported() -> Json { } async fn settle_count_handler(State(state): State) -> Json { - let n = state.settle_count.load(Ordering::SeqCst); + let n = state.settle_count.load(Ordering::Relaxed); Json(serde_json::json!({ "settle_count": n })) } @@ -267,7 +264,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(state.settle_count.load(Ordering::SeqCst), 0); + assert_eq!(state.settle_count.load(Ordering::Relaxed), 0); // settle increments let _ = app @@ -280,6 +277,6 @@ mod tests { ) .await .unwrap(); - assert_eq!(state.settle_count.load(Ordering::SeqCst), 1); + assert_eq!(state.settle_count.load(Ordering::Relaxed), 1); } } From ae0298382547b95eb3bb8db72f0036a65e777f31 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Fri, 15 May 2026 22:46:19 +0000 Subject: [PATCH 10/11] docs(x402): update demo playbook + gateway README post-simplify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the /simplify pass deleted `x402_payai_devnet_test.rs` and the hand-rolled Rust Solana client, the docs were pointing at removed commands and out-of-date output snippets. Rewrite the affected sections. Playbook (`docs/x402-devnet-playbook.md`): - Step 2 (pin the TVC pubkey): collapse from a multi-line "cargo run this, or fall back to python, or set a wrong value and read the error" recipe to a single `cat fixtures/ephemeral.pub` into the env var. The public half is literally already there in the repo. - Step 4 (confirm 402): show the actual network identifier payai emits on devnet (`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`, the CAIP-2 form), and how to decode the base64 `payment-required` body inline. - Step 5 (run TS demo): switch invocation from `npx tsx ...` to `node --experimental-strip-types --no-warnings scripts/...`. Node 22+ strips TS types natively; tsx remains optional. Replace the expected- output block with the actual output from a fresh run, including ASCII section banners (the demo now uses `--` not `--`). - Step 7 (cargo gated devnet test): remove. There is no `x402_payai_devnet_test` anymore — the TS demo + playbook is the canonical way to validate real-payai-devnet end-to-end. - Failures table: drop the stale `buyer ATA … has only N USDC atoms` panic message and add three real failure modes I hit while bringing this up: the busybox-no-sh entrypoint, the missing-CA-certs panic, and the client/gateway pubkey mismatch. Gateway README (`src/parser/gateway/README.md`): - Drop the `X402_E2E=1 cargo test --test x402_payai_devnet_test` instructions for the same reason. Point readers at the playbook for the live-devnet path. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/x402-devnet-playbook.md | 139 +++++++++++++++++++---------------- src/parser/gateway/README.md | 11 ++- 2 files changed, 81 insertions(+), 69 deletions(-) diff --git a/docs/x402-devnet-playbook.md b/docs/x402-devnet-playbook.md index b777d6f4..06b54924 100644 --- a/docs/x402-devnet-playbook.md +++ b/docs/x402-devnet-playbook.md @@ -122,36 +122,23 @@ docker image ls | grep anchorageoss-visualsign-parser You should see all four. Build takes ~5 min cold, ~30 sec warm. -### Step 2 — Compute the pinned TVC verifier pubkey +### Step 2 — Set the pinned TVC verifier pubkey The gateway must be told the enclave's expected ephemeral public key at -launch. For local-dev we re-use the test fixture key: +launch. For local-dev the fixture keypair is committed to the repo and +the *public* half is right there in `fixtures/ephemeral.pub` — just +`cat` it into the env var: ```sh -cd src -cargo run -q --bin print_ephemeral_pubkey 2>/dev/null || \ - cargo test -p integration --test x402_payai_devnet_test load_devnet_keypair_round_trips -- --nocapture 2>&1 \ - | grep -E '^\[fixture\]' || true - -# Easier: derive the hex inline (matches what parser_grpc_server will sign with) -EPH_HEX=$(cargo run --quiet -p integration --example print_tvc_pubkey 2>/dev/null \ - || python3 - <<'PY' -# Fallback: extract from fixtures/ephemeral.pub if present -import sys, pathlib -p = pathlib.Path("integration/fixtures/ephemeral.pub") -print(p.read_text().strip()) -PY -) -echo "TVC pubkey hex: $EPH_HEX" -cd .. -export X402_TVC_VERIFIER_PUBKEY_HEX="$EPH_HEX" +export X402_TVC_VERIFIER_PUBKEY_HEX=$(cat src/integration/fixtures/ephemeral.pub) ``` -If you don't have a quick way to print the hex, run the gateway once with -`X402_TVC_VERIFIER_PUBKEY_HEX=00...00` (any wrong value). It will start, -serve a request, log `attestation verification failed: public key -mismatch: ... response key `. Copy the real hex, kill the -gateway, and re-export. +This is the same value `parser_grpc_server` will sign every parse +response with, so the gateway's verification will pass. + +In production TVC, the enclave's `parser_app` is provisioned by Turnkey +with a *different* ephemeral key and exposes its public half through the +attested boot record. Part 2 walks through how to read that and pin it. ### Step 3 — Fund the reproducible test wallet @@ -207,57 +194,92 @@ in the logs: ``` x402 facilitator probe OK -x402 attestation: pinned TVC pubkey 04abc123… -parser_gateway v… listening on 0.0.0.0:8080 +x402 attestation: pinned TVC pubkey 04716208..ed68bd57 +parser_gateway dev listening on 0.0.0.0:8080 ``` Confirm the 402 challenge directly: ```sh -curl -i -X POST http://127.0.0.1:8080/visualsign/api/v2/parse \ +curl -s -i -X POST http://127.0.0.1:8080/visualsign/api/v2/parse \ -H 'content-type: application/json' \ -d '{"request":{"unsigned_payload":"0xdeadbeef","chain":"CHAIN_ETHEREUM"}}' \ - | head -20 + | head -3 ``` Expected: `HTTP/1.1 402 Payment Required` + a `payment-required` header -whose base64-JSON includes a `solana-devnet` entry. +whose base64-JSON includes an entry with network +`solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` (the CAIP-2 form of Solana +devnet that payai emits). To peek inside: + +```sh +HDR=$(curl -s -i -X POST http://127.0.0.1:8080/visualsign/api/v2/parse \ + -H 'content-type: application/json' \ + -d '{"request":{"unsigned_payload":"0xdeadbeef","chain":"CHAIN_ETHEREUM"}}' \ + | awk -F': ' 'tolower($1)=="payment-required"{print $2}' | tr -d '\r') +echo "$HDR" | base64 -d | python3 -m json.tool +``` ### Step 5 — Run the TS demo client to pay & parse ```sh cd scripts -npm install # one-time +npm install # one-time +cd .. export GATEWAY_URL=http://127.0.0.1:8080 export RPC_URL=https://api.devnet.solana.com -npx tsx x402-solana-devnet-demo.ts +node --experimental-strip-types --no-warnings scripts/x402-solana-devnet-demo.ts ``` +(`tsx` works too, but Node 22.6+ strips TS types natively — no +build/transpile step needed.) + What you should see: ``` -── Wallet ─────────────────────────────────… +-- Wallet ----------------------------------------------------------- buyer address : x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW -buyer balance : 1.9234 SOL on devnet -── Probe 402 ──────────────────────────────… -accepts: solana-devnet -price: 1000 atoms USDC -> x2iWww6X… on solana-devnet -── Sign X-PAYMENT ─────────────────────────… -header length: 1842 chars -── Paid request ───────────────────────────… +buyer balance : 5.0000 SOL on devnet + +-- x402 client (payai/x402-solana) ---------------------------------- +client constructed; making paid request... + +-- Paid POST /visualsign/api/v2/parse ------------------------------- +[x402-solana] Making initial request to: http://127.0.0.1:8080/visualsign/api/v2/parse +[x402-solana] Initial response status: 402 +[x402-solana] Got 402, parsing payment requirements... +[x402-solana] Creating signed transaction... +[x402-solana] Transaction signed successfully +[x402-solana] Retrying request with PAYMENT-SIGNATURE header... +[x402-solana] Retry response status: 200 status: 200 -X-PAYMENT-RESPONSE: eyJzdWNjZX… -signature pubkey: 04abc123… -── Independent P256 verification ──────────… +settlement: { + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "payer": "x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW", + "success": true, + "transaction": "" +} + +-- Independent P256 verification ------------------------------------ response signature verifies against pinned TVC pubkey ✓ -── Done ───────────────────────────────────… -payload bytes: 1284 + +-- Done ------------------------------------------------------------- +payload bytes: 734 ``` -The `solana-devnet ✓` line is the assertion that closes the loop: the -gateway returned a 200 *and* the response signature verifies against the -pinned enclave pubkey using `@noble/curves/p256` (cross-impl with the -Rust verifier). +Three independent things just happened: + +1. **payai settled a real USDC transfer on Solana devnet.** Paste the + `settlement.transaction` value into + `https://explorer.solana.com/tx/?cluster=devnet` to see + it on chain. +2. **The gateway returned 200,** meaning its server-side P256 + verification of the parse response against the pinned TVC pubkey + passed. A 502 here would mean settlement skipped (no charge). +3. **The TS demo independently re-verified the response signature** + using `@noble/curves/p256` against the same pinned pubkey, proving + you don't have to trust the gateway's word — any consumer can run + the same check. ### Step 6 — Watch the gateway logs @@ -269,7 +291,7 @@ make dev-logs You'll see one of two flows per request: -- `(verified)` and `x402 settled in ` for a happy path +- `attestation: pinned TVC pubkey …` at startup, then quiet 200s - `attestation verification failed: …` + the 502 — if you ever boot the gateway with a wrong `X402_TVC_VERIFIER_PUBKEY_HEX`, this is what prevents payment for an unattested response. @@ -280,26 +302,17 @@ You'll see one of two flows per request: make dev-down ``` -### Run the gated devnet test from cargo (optional) - -```sh -cd src -X402_E2E=1 cargo test -p integration --test x402_payai_devnet_test \ - -- --ignored --nocapture -``` - -This boots its own stack (the same binaries, run natively rather than in -containers) and runs the same end-to-end flow from Rust. Useful as a -regression gate in a CI job labeled `e2e-devnet`. - ### Common failures (Part 1) | Symptom | Cause | Fix | | --- | --- | --- | | `WARNING: x402 disabled; facilitator probe failed` | No egress to `facilitator.payai.network` | Check VPN / corp proxy; the v2 route stays unmounted otherwise | | `FATAL: X402_TVC_VERIFIER_PUBKEY_HEX … required for X402_PROFILE=payai` | Forgot to export the pubkey | See Step 2 | -| `buyer ATA … has only N USDC atoms` (panic from cargo test) | Wallet underfunded | faucet.circle.com | -| `attestation verification failed: public key mismatch` on every request | Wrong pubkey pinned (env stale, or the parser_grpc_server image rebuilt with a different ephemeral fixture) | Re-derive the hex from `fixtures/ephemeral.pub` | +| Demo aborts with `paid request failed: 402` | Wallet underfunded on devnet (USDC or SOL) | Top up via faucet.circle.com / faucet.solana.com | +| `attestation verification failed: public key mismatch` on every request | Wrong pubkey pinned (env stale) | Re-export `X402_TVC_VERIFIER_PUBKEY_HEX` from `fixtures/ephemeral.pub` | +| Demo throws `independent P256 verification FAILED` | `X402_TVC_VERIFIER_PUBKEY_HEX` set differently for the gateway vs the demo | Use the same value in both shells | +| Container immediately exits with `syntax error: unterminated quoted string` | You replaced `entrypoint:` with `command:` in compose | Restore `entrypoint: ["/binary"]` — stagex/core-busybox sets ENTRYPOINT=`/bin/sh` | +| Gateway crashes at startup with `No CA certificates were loaded from the system` | Building from a Containerfile that didn't COPY the CA bundle | Pull CA certs from `stagex/core-ca-certificates` into `/etc/ssl/certs/` (see `images/parser_gateway/Containerfile`) | | Demo hangs on "Sign X-PAYMENT" | Solana devnet RPC slow / blockhash fetch timeout | Switch `RPC_URL` to your own RPC endpoint | --- diff --git a/src/parser/gateway/README.md b/src/parser/gateway/README.md index 2e1f1eb2..494b6de8 100644 --- a/src/parser/gateway/README.md +++ b/src/parser/gateway/README.md @@ -122,11 +122,10 @@ devnet with SOL + USDC before running. See ## Integration tests ```sh -# Always-on (offline, mock facilitator) — 6 paths including signature -# tamper detection. +# Offline, mock facilitator. 6 paths including signature-tamper +# detection (Path 6: pinned-pubkey mismatch -> 502 and no settlement). make -C src test - -# Gated devnet E2E (real payai + Solana devnet). Requires the -# reproducible buyer wallet to be funded. -X402_E2E=1 cargo test -p integration --test x402_payai_devnet_test -- --ignored ``` + +For real-payai + Solana devnet end-to-end, drive the demo TS client +against a `make dev-up-payai` stack — see `docs/x402-devnet-playbook.md`. From f3aeed2fe08890cc8a4743b3b1498da4a6603707 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Sat, 16 May 2026 02:19:43 +0000 Subject: [PATCH 11/11] refactor(x402): rename TVC env var + mark response verifier as demo-only Two related cleanups before the TVC-enforced-payment work lands as a stacked PR: - Rename `X402_TVC_VERIFIER_PUBKEY_HEX` / `_FILE` to `TVC_DEMO_PINNED_PUBKEY_HEX` / `_FILE`. The old name conflated this hex-encoded P256 compound key with Solana's base58 "pubkey" namespace and gave the impression that pinning a key in an env var was the intended production model. The new name says plainly: this is a demo knob, not a production attestation flow. - Expand the doc-mark on `src/parser/gateway/src/attestation.rs` to call out exactly what's missing for production: parse + validate an AWS Nitro attestation document (cert chain + COSE Sign1 + PCRs + manifest hash), extract the embedded ephemeral pubkey, use *that* for per-response verification. Pointer to the canonical Rust validator already published by Turnkey: `tkhq/rust-sdk::proofs::parse_and_verify_aws_nitro_attestation`. Touches: gateway source (`attestation.rs`, `main.rs`), README, the devnet playbook, both compose files, the integration tests, scripts, Makefile dev-up-payai target, and the stagex.yml release-notes block. Verification: clippy clean, fmt clean, 26 gateway lib tests + 6 integration paths pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/stagex.yml | 4 +- Makefile | 6 +-- compose.mock.yml | 2 +- compose.payai.yml | 6 +-- docs/x402-devnet-playbook.md | 32 ++++++------ scripts/x402-solana-devnet-demo.ts | 6 +-- src/integration/tests/x402_gateway_test.rs | 4 +- src/parser/gateway/README.md | 12 ++--- src/parser/gateway/src/attestation.rs | 59 ++++++++++++++++------ src/parser/gateway/src/main.rs | 4 +- 10 files changed, 81 insertions(+), 54 deletions(-) mode change 100644 => 100755 scripts/x402-solana-devnet-demo.ts diff --git a/.github/workflows/stagex.yml b/.github/workflows/stagex.yml index f0cc56be..e8b383c5 100644 --- a/.github/workflows/stagex.yml +++ b/.github/workflows/stagex.yml @@ -163,9 +163,9 @@ jobs: # against a pinned pubkey + handles x402 settlement). Operators # need both digests pinned to actually establish the trust pair. if [ "${TARGET_NAME}" = "parser_app" ]; then - role_note=$'\nThe enclave-side binary. Its ephemeral public key, signed by QOS during boot, becomes the gateway-side `X402_TVC_VERIFIER_PUBKEY_HEX`.' + role_note=$'\nThe enclave-side binary. Its ephemeral public key, signed by QOS during boot, becomes the gateway-side `TVC_DEMO_PINNED_PUBKEY_HEX`.' else - role_note=$'\nThe host-side gateway. Pin the matching `parser_app` digest as the enclave it talks to; the gateway verifies every response against the enclave\'s ephemeral pubkey (pinned at boot via `X402_TVC_VERIFIER_PUBKEY_HEX` or `X402_TVC_VERIFIER_PUBKEY_FILE`).' + role_note=$'\nThe host-side gateway. Pin the matching `parser_app` digest as the enclave it talks to; the gateway verifies every response against the enclave\'s ephemeral pubkey (pinned at boot via `TVC_DEMO_PINNED_PUBKEY_HEX` or `TVC_DEMO_PINNED_PUBKEY_FILE`).' fi deploy_md="${RUNNER_TEMP}/tvc_deploy_${TARGET_NAME}.md" diff --git a/Makefile b/Makefile index 13da9d07..1d4bd855 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ non-oci-docker-images: # `dev-up-payai` — real-facilitator stack: parser_grpc_server + parser_gateway # pointed at https://facilitator.payai.network with # X402_NETWORK=solana-devnet. Requires public egress. -# Set X402_TVC_VERIFIER_PUBKEY_HEX before running this +# Set TVC_DEMO_PINNED_PUBKEY_HEX before running this # target; otherwise the gateway fail-closes. # Both compose files consume the locally-built stagex images. Build them # first with `make non-oci-docker-images`. @@ -43,8 +43,8 @@ dev-up-mock: non-oci-docker-images docker compose -f compose.mock.yml up -d dev-up-payai: non-oci-docker-images - @if [ -z "$$X402_TVC_VERIFIER_PUBKEY_HEX" ]; then \ - echo "ERROR: X402_TVC_VERIFIER_PUBKEY_HEX must be set (the gateway fail-closes without it for X402_PROFILE=payai)."; \ + @if [ -z "$$TVC_DEMO_PINNED_PUBKEY_HEX" ]; then \ + echo "ERROR: TVC_DEMO_PINNED_PUBKEY_HEX must be set (the gateway fail-closes without it for X402_PROFILE=payai)."; \ exit 1; \ fi docker compose -f compose.payai.yml up -d diff --git a/compose.mock.yml b/compose.mock.yml index 8db5acca..38bcec7d 100644 --- a/compose.mock.yml +++ b/compose.mock.yml @@ -39,7 +39,7 @@ services: GRPC_ADDR: "http://parser_grpc_server:44020" X402_PROFILE: "local" X402_FACILITATOR_URL: "http://mock_facilitator:8090" - # X402_TVC_VERIFIER_PUBKEY_HEX intentionally omitted: local profile + # TVC_DEMO_PINNED_PUBKEY_HEX intentionally omitted: local profile # allows running without attestation. Set this env var here to opt # into verification locally (the gateway will fail on mismatch). ports: diff --git a/compose.payai.yml b/compose.payai.yml index 4d0d1b1e..add7a04e 100644 --- a/compose.payai.yml +++ b/compose.payai.yml @@ -6,7 +6,7 @@ # X402_PAYTO= # — where payai will route the USDC. For local testing this can be # the buyer wallet itself for a self-transfer. -# X402_TVC_VERIFIER_PUBKEY_HEX= +# TVC_DEMO_PINNED_PUBKEY_HEX= # — pinned ephemeral key the gateway will verify against. Without this # the gateway fail-closes (X402_PROFILE != local). # @@ -24,7 +24,7 @@ services: EPHEMERAL_FILE: "/etc/parser/ephemeral.secret" volumes: # Local-dev only. Production TVC provisions the real key via QoS host - # boot; the public half ends up as X402_TVC_VERIFIER_PUBKEY_HEX on the + # boot; the public half ends up as TVC_DEMO_PINNED_PUBKEY_HEX on the # gateway side. - ./src/integration/fixtures/ephemeral.secret:/etc/parser/ephemeral.secret:ro ports: @@ -43,6 +43,6 @@ services: X402_FACILITATOR_URL: "https://facilitator.payai.network" X402_FACILITATOR_TIMEOUT_SECS: "10" X402_PAYTO: "${X402_PAYTO:?X402_PAYTO must be set in your shell before docker compose up}" - X402_TVC_VERIFIER_PUBKEY_HEX: "${X402_TVC_VERIFIER_PUBKEY_HEX:?required: pin the TVC enclave's P256 public key here}" + TVC_DEMO_PINNED_PUBKEY_HEX: "${TVC_DEMO_PINNED_PUBKEY_HEX:?required: pin the TVC enclave's P256 public key here}" ports: - "8080:8080" diff --git a/docs/x402-devnet-playbook.md b/docs/x402-devnet-playbook.md index 06b54924..3ac990a2 100644 --- a/docs/x402-devnet-playbook.md +++ b/docs/x402-devnet-playbook.md @@ -91,9 +91,9 @@ git checkout spec/x402-gated-http-api make non-oci-docker-images # build stagex images cd scripts && npm install && cd .. # one-time TS deps export X402_PAYTO=x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW -export X402_TVC_VERIFIER_PUBKEY_HEX=$(make print-tvc-pubkey-hex) +export TVC_DEMO_PINNED_PUBKEY_HEX=$(make print-tvc-pubkey-hex) make dev-up-payai -X402_TVC_VERIFIER_PUBKEY_HEX=$X402_TVC_VERIFIER_PUBKEY_HEX \ +TVC_DEMO_PINNED_PUBKEY_HEX=$TVC_DEMO_PINNED_PUBKEY_HEX \ npx --prefix scripts tsx scripts/x402-solana-devnet-demo.ts make dev-down ``` @@ -130,7 +130,7 @@ the *public* half is right there in `fixtures/ephemeral.pub` — just `cat` it into the env var: ```sh -export X402_TVC_VERIFIER_PUBKEY_HEX=$(cat src/integration/fixtures/ephemeral.pub) +export TVC_DEMO_PINNED_PUBKEY_HEX=$(cat src/integration/fixtures/ephemeral.pub) ``` This is the same value `parser_grpc_server` will sign every parse @@ -183,7 +183,7 @@ USDC moved goes back to the same account. Override with a different ```sh export X402_PAYTO="$ADDR" -export X402_TVC_VERIFIER_PUBKEY_HEX # already exported in Step 2 +export TVC_DEMO_PINNED_PUBKEY_HEX # already exported in Step 2 make dev-up-payai ``` @@ -293,7 +293,7 @@ You'll see one of two flows per request: - `attestation: pinned TVC pubkey …` at startup, then quiet 200s - `attestation verification failed: …` + the 502 — if you ever boot the - gateway with a wrong `X402_TVC_VERIFIER_PUBKEY_HEX`, this is what + gateway with a wrong `TVC_DEMO_PINNED_PUBKEY_HEX`, this is what prevents payment for an unattested response. ### Step 7 — Tear down @@ -307,10 +307,10 @@ make dev-down | Symptom | Cause | Fix | | --- | --- | --- | | `WARNING: x402 disabled; facilitator probe failed` | No egress to `facilitator.payai.network` | Check VPN / corp proxy; the v2 route stays unmounted otherwise | -| `FATAL: X402_TVC_VERIFIER_PUBKEY_HEX … required for X402_PROFILE=payai` | Forgot to export the pubkey | See Step 2 | +| `FATAL: TVC_DEMO_PINNED_PUBKEY_HEX … required for X402_PROFILE=payai` | Forgot to export the pubkey | See Step 2 | | Demo aborts with `paid request failed: 402` | Wallet underfunded on devnet (USDC or SOL) | Top up via faucet.circle.com / faucet.solana.com | -| `attestation verification failed: public key mismatch` on every request | Wrong pubkey pinned (env stale) | Re-export `X402_TVC_VERIFIER_PUBKEY_HEX` from `fixtures/ephemeral.pub` | -| Demo throws `independent P256 verification FAILED` | `X402_TVC_VERIFIER_PUBKEY_HEX` set differently for the gateway vs the demo | Use the same value in both shells | +| `attestation verification failed: public key mismatch` on every request | Wrong pubkey pinned (env stale) | Re-export `TVC_DEMO_PINNED_PUBKEY_HEX` from `fixtures/ephemeral.pub` | +| Demo throws `independent P256 verification FAILED` | `TVC_DEMO_PINNED_PUBKEY_HEX` set differently for the gateway vs the demo | Use the same value in both shells | | Container immediately exits with `syntax error: unterminated quoted string` | You replaced `entrypoint:` with `command:` in compose | Restore `entrypoint: ["/binary"]` — stagex/core-busybox sets ENTRYPOINT=`/bin/sh` | | Gateway crashes at startup with `No CA certificates were loaded from the system` | Building from a Containerfile that didn't COPY the CA bundle | Pull CA certs from `stagex/core-ca-certificates` into `/etc/ssl/certs/` (see `images/parser_gateway/Containerfile`) | | Demo hangs on "Sign X-PAYMENT" | Solana devnet RPC slow / blockhash fetch timeout | Switch `RPC_URL` to your own RPC endpoint | @@ -356,7 +356,7 @@ tvc deploy create tvc-deploy.json Once the deploy reaches "running", **read back the enclave's ephemeral public key** from the TVC console or API. This is the value you pin -into the gateway as `X402_TVC_VERIFIER_PUBKEY_HEX`. +into the gateway as `TVC_DEMO_PINNED_PUBKEY_HEX`. In a typical Turnkey TVC deploy this surfaces as a field on the deployed app's attested boot record (the value `parser_app` writes when it loads @@ -387,7 +387,7 @@ exposes for your deploy. # enclave instead). Set GRPC_ADDR to the enclave URL. export X402_PAYTO= -export X402_TVC_VERIFIER_PUBKEY_HEX="$TVC_ENCLAVE_PUBKEY" +export TVC_DEMO_PINNED_PUBKEY_HEX="$TVC_ENCLAVE_PUBKEY" docker compose -f compose.payai.yml up ``` @@ -408,7 +408,7 @@ X402_NETWORK=solana-devnet # or solana on mainnet X402_FACILITATOR_URL=https://facilitator.payai.network X402_FACILITATOR_TIMEOUT_SECS=10 X402_PAYTO= -X402_TVC_VERIFIER_PUBKEY_HEX= +TVC_DEMO_PINNED_PUBKEY_HEX= ``` The image to pull is `ghcr.io/anchorageoss/parser_gateway:vX.Y.Z@sha256:` @@ -437,7 +437,7 @@ pubkey: cd scripts GATEWAY_URL=https:// \ RPC_URL=https://api.devnet.solana.com \ -X402_TVC_VERIFIER_PUBKEY_HEX="$TVC_ENCLAVE_PUBKEY" \ +TVC_DEMO_PINNED_PUBKEY_HEX="$TVC_ENCLAVE_PUBKEY" \ npx tsx x402-solana-devnet-demo.ts ``` @@ -453,7 +453,7 @@ Set the pubkey env to a single-bit-wrong value and re-run the client: ```sh WRONG=$(echo "$TVC_ENCLAVE_PUBKEY" | sed 's/.$/0/' ) GATEWAY_URL=https:// \ -X402_TVC_VERIFIER_PUBKEY_HEX="$WRONG" \ +TVC_DEMO_PINNED_PUBKEY_HEX="$WRONG" \ npx tsx x402-solana-devnet-demo.ts ``` @@ -466,9 +466,9 @@ trust in the gateway's word.) | Symptom | Cause | Fix | | --- | --- | --- | -| `FATAL: X402_TVC_VERIFIER_PUBKEY_HEX … required` at gateway boot | Env not propagated | Check your TVC deploy manifest's env block | -| Gateway returns 502 on every request | `X402_TVC_VERIFIER_PUBKEY_HEX` doesn't match the live enclave's ephemeral key | Re-read the pubkey from the enclave's attested boot record after re-deploy; rotating the parser_app deploy generates a new ephemeral key | -| Gateway 200 but client `Independent P256 verification FAILED` | You pinned the wrong pubkey *only on the client* (gateway has the right one) | Re-export `X402_TVC_VERIFIER_PUBKEY_HEX` for the client | +| `FATAL: TVC_DEMO_PINNED_PUBKEY_HEX … required` at gateway boot | Env not propagated | Check your TVC deploy manifest's env block | +| Gateway returns 502 on every request | `TVC_DEMO_PINNED_PUBKEY_HEX` doesn't match the live enclave's ephemeral key | Re-read the pubkey from the enclave's attested boot record after re-deploy; rotating the parser_app deploy generates a new ephemeral key | +| Gateway 200 but client `Independent P256 verification FAILED` | You pinned the wrong pubkey *only on the client* (gateway has the right one) | Re-export `TVC_DEMO_PINNED_PUBKEY_HEX` for the client | | Client gets `402` even after sending X-PAYMENT | x402-axum middleware rejected the header (malformed amount, wrong network, expired blockhash) | Inspect gateway logs; the most common cause is a stale blockhash — retry within ~90 s of building the tx | --- diff --git a/scripts/x402-solana-devnet-demo.ts b/scripts/x402-solana-devnet-demo.ts old mode 100644 new mode 100755 index 7408ea41..65453189 --- a/scripts/x402-solana-devnet-demo.ts +++ b/scripts/x402-solana-devnet-demo.ts @@ -24,7 +24,7 @@ * GATEWAY_URL default http://127.0.0.1:8080 * RPC_URL default https://api.devnet.solana.com * WALLET_SEED default ../src/integration/fixtures/devnet/wallet.seed - * X402_TVC_VERIFIER_PUBKEY_HEX + * TVC_DEMO_PINNED_PUBKEY_HEX * if set, the demo independently P256-verifies the response * signature against this key (cross-impl check vs the * gateway). @@ -40,7 +40,7 @@ import { createX402Client, type WalletAdapter } from "x402-solana/client"; const GATEWAY_URL = process.env.GATEWAY_URL ?? "http://127.0.0.1:8080"; const RPC_URL = process.env.RPC_URL ?? "https://api.devnet.solana.com"; -const TVC_HEX = process.env.X402_TVC_VERIFIER_PUBKEY_HEX?.toLowerCase(); +const TVC_HEX = process.env.TVC_DEMO_PINNED_PUBKEY_HEX?.toLowerCase(); async function loadBuyerKeypair(): Promise { const __filename = fileURLToPath(import.meta.url); @@ -148,7 +148,7 @@ async function main(): Promise { logSection("Independent P256 verification"); if (sig.publicKey.toLowerCase() !== TVC_HEX) { throw new Error( - `response pubkey ${sig.publicKey} != X402_TVC_VERIFIER_PUBKEY_HEX`, + `response pubkey ${sig.publicKey} != TVC_DEMO_PINNED_PUBKEY_HEX`, ); } // qos_p256 encodes P256Public as encrypt_public || sign_public, each diff --git a/src/integration/tests/x402_gateway_test.rs b/src/integration/tests/x402_gateway_test.rs index d3ad3881..f4200b2e 100644 --- a/src/integration/tests/x402_gateway_test.rs +++ b/src/integration/tests/x402_gateway_test.rs @@ -75,7 +75,7 @@ async fn wait_ready(url: &str) { /// Load the test ephemeral key and return its `qos_hex` pubkey — the exact /// format parser_app emits in the wire signature and the gateway pins via -/// `X402_TVC_VERIFIER_PUBKEY_HEX`. +/// `TVC_DEMO_PINNED_PUBKEY_HEX`. fn fixture_ephemeral_pubkey_hex() -> String { let pair = P256Pair::from_hex_file("fixtures/ephemeral.secret") .expect("load fixtures/ephemeral.secret"); @@ -418,7 +418,7 @@ async fn path6_tampered_pubkey_returns_502_no_settle() { // Sanity: must differ from the fixture's pubkey. assert_ne!(wrong_hex, fixture_ephemeral_pubkey_hex()); - let _p = start_procs(&[("X402_TVC_VERIFIER_PUBKEY_HEX", wrong_hex.as_str())]).await; + let _p = start_procs(&[("TVC_DEMO_PINNED_PUBKEY_HEX", wrong_hex.as_str())]).await; // Read settle_count before the request. let before = read_settle_count().await; diff --git a/src/parser/gateway/README.md b/src/parser/gateway/README.md index 494b6de8..6ecf5b20 100644 --- a/src/parser/gateway/README.md +++ b/src/parser/gateway/README.md @@ -32,9 +32,9 @@ exact format `parser_app` emits in the wire signature's `publicKey` field. ```sh # Set by TVC at boot (or via your local-dev compose file) -X402_TVC_VERIFIER_PUBKEY_HEX=<260 hex chars> +TVC_DEMO_PINNED_PUBKEY_HEX=<260 hex chars> # Or, equivalently: -X402_TVC_VERIFIER_PUBKEY_FILE=/path/to/pubkey.hex +TVC_DEMO_PINNED_PUBKEY_FILE=/path/to/pubkey.hex ``` If neither is set: @@ -55,8 +55,8 @@ All env vars are read at startup. Bad values fail-closed (gateway exits 1). | `X402_NETWORK` | no | profile-default | `base-sepolia`, `base`, `solana`, `solana-devnet` | | `X402_PAYTO` | depends | burn address for `local` | EVM `0x…` or Solana base58 | | `X402_PRICE_TAGS_JSON` | no | seeded from profile + `X402_NETWORK` | full multi-tag override; see the JSON shape in `x402_config.rs` | -| `X402_TVC_VERIFIER_PUBKEY_HEX` | **yes** (non-local) | — | pinned enclave pubkey, hex | -| `X402_TVC_VERIFIER_PUBKEY_FILE` | no | — | alternative to `_HEX`: file holding the hex | +| `TVC_DEMO_PINNED_PUBKEY_HEX` | **yes** (non-local) | — | pinned enclave pubkey, hex | +| `TVC_DEMO_PINNED_PUBKEY_FILE` | no | — | alternative to `_HEX`: file holding the hex | ### Profiles @@ -87,7 +87,7 @@ make dev-up-mock # Real payai facilitator on Solana devnet. export X402_PAYTO= -export X402_TVC_VERIFIER_PUBKEY_HEX=<260-char hex from parser_app> +export TVC_DEMO_PINNED_PUBKEY_HEX=<260-char hex from parser_app> make dev-up-payai # Tear down either stack. @@ -109,7 +109,7 @@ Solana devnet: cd scripts npm install GATEWAY_URL=http://127.0.0.1:8080 \ -X402_TVC_VERIFIER_PUBKEY_HEX=<260-char hex> \ +TVC_DEMO_PINNED_PUBKEY_HEX=<260-char hex> \ npx tsx x402-solana-devnet-demo.ts ``` diff --git a/src/parser/gateway/src/attestation.rs b/src/parser/gateway/src/attestation.rs index 370d8bc8..ac2e2c66 100644 --- a/src/parser/gateway/src/attestation.rs +++ b/src/parser/gateway/src/attestation.rs @@ -1,15 +1,42 @@ -//! Verifies that a parse response was signed by a pinned TVC (Turnkey Verifiable -//! Compute) ephemeral key. +//! **Demo-only response-signature verification.** The gateway pins one +//! `qos_p256::P256Public` value at boot via env var and rejects any parse +//! response whose `Signature.public_key` doesn't match. //! -//! The gateway sits between an HTTP client (which may be paying via x402) and the -//! parser_app gRPC service. parser_app signs every response with an ephemeral -//! P256 keypair provisioned into the enclave by Turnkey. The gateway must refuse -//! to release the response to the client (and skip x402 settlement) unless the -//! signature verifies against a TVC pubkey that was pinned at the gateway's -//! launch time. The pubkey is provided via env var (`X402_TVC_VERIFIER_PUBKEY_HEX`, -//! or a file path via `X402_TVC_VERIFIER_PUBKEY_FILE`) and matches the same -//! `qos_hex::encode(P256Public::to_bytes())` format parser_app emits in the wire -//! signature. +//! This is NOT a production attestation flow. It does not parse or validate +//! an AWS Nitro / Intel TDX attestation document; it does not check PCRs; it +//! does not verify Turnkey operator signatures over the deploy manifest. It +//! assumes someone else (a TVC operator, an ops engineer, the demo playbook) +//! put a trustworthy pubkey hex in the env var. +//! +//! ## Production replacement +//! +//! In a real Turnkey TVC deployment, replace this with the real attestation +//! chain. The Turnkey Rust SDK already exposes the validator: +//! +//! - `tkhq/rust-sdk` → `proofs::parse_and_verify_aws_nitro_attestation` +//! +//! +//! Sketch of the production path: +//! 1. parser_app exposes its Nitro attestation document via a new +//! `GetAttestation` gRPC method (it already holds one from QOS boot). +//! 2. Gateway at startup fetches the doc, calls +//! `parse_and_verify_aws_nitro_attestation(doc, expected_pcrs)`, and +//! extracts the embedded ephemeral pubkey from the returned struct. +//! 3. That extracted pubkey is what gets used for per-response P256 verify +//! — same wire path as today, just sourced from attestation instead of +//! an env var. +//! +//! Until that lands, this module's `from_env()` is the demo crutch. +//! +//! ## Env vars (demo only) +//! +//! - `TVC_DEMO_PINNED_PUBKEY_HEX` — hex of `P256Public::to_bytes()`. +//! - `TVC_DEMO_PINNED_PUBKEY_FILE` — file containing the same hex. +//! +//! The hex is the qos_p256 compound key (encrypt half || sign half, each +//! SEC1 uncompressed) — 130 bytes / 260 hex chars. This is NOT a Solana +//! base58 address; the two share the word "pubkey" but live in different +//! namespaces. use generated::parser::{Signature, SignatureScheme}; use qos_p256::P256Public; @@ -42,8 +69,8 @@ pub struct AttestationVerifier { impl AttestationVerifier { /// Production entrypoint — reads from the real process environment. /// - /// Returns `Ok(None)` if neither `X402_TVC_VERIFIER_PUBKEY_HEX` nor - /// `X402_TVC_VERIFIER_PUBKEY_FILE` is set. Callers decide whether absence + /// Returns `Ok(None)` if neither `TVC_DEMO_PINNED_PUBKEY_HEX` nor + /// `TVC_DEMO_PINNED_PUBKEY_FILE` is set. Callers decide whether absence /// is fatal based on profile (production deployments fail closed; local /// dev runs without a pinned verifier). pub fn from_env() -> Result, AttestationError> { @@ -57,8 +84,8 @@ impl AttestationVerifier { F: Fn(&str) -> Option, { let hex_value = match ( - get("X402_TVC_VERIFIER_PUBKEY_HEX"), - get("X402_TVC_VERIFIER_PUBKEY_FILE"), + get("TVC_DEMO_PINNED_PUBKEY_HEX"), + get("TVC_DEMO_PINNED_PUBKEY_FILE"), ) { (Some(s), _) => s, (None, Some(path)) => std::fs::read_to_string(&path) @@ -77,7 +104,7 @@ impl AttestationVerifier { pub fn from_hex(hex_value: &str) -> Result { let pinned_bytes = qos_hex::decode(hex_value.trim()).map_err(|e| AttestationError::Hex { - field: "X402_TVC_VERIFIER_PUBKEY_HEX", + field: "TVC_DEMO_PINNED_PUBKEY_HEX", message: format!("{e:?}"), })?; let pinned_public = P256Public::from_bytes(&pinned_bytes) diff --git a/src/parser/gateway/src/main.rs b/src/parser/gateway/src/main.rs index 46d72a4c..ba2b1cea 100644 --- a/src/parser/gateway/src/main.rs +++ b/src/parser/gateway/src/main.rs @@ -56,13 +56,13 @@ async fn main() -> Result<(), Box> { Ok(None) => { if is_local_profile { eprintln!( - "WARNING: X402_TVC_VERIFIER_PUBKEY_HEX not set; gateway will not attest \ + "WARNING: TVC_DEMO_PINNED_PUBKEY_HEX not set; gateway will not attest \ parse responses (allowed because X402_PROFILE=local)" ); None } else { eprintln!( - "FATAL: X402_TVC_VERIFIER_PUBKEY_HEX (or _FILE) is required for \ + "FATAL: TVC_DEMO_PINNED_PUBKEY_HEX (or _FILE) is required for \ X402_PROFILE={profile_str}" ); std::process::exit(1);