diff --git a/.github/workflows/stagex.yml b/.github/workflows/stagex.yml index 8a7a4b616..e8b383c54 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 `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 `TVC_DEMO_PINNED_PUBKEY_HEX` or `TVC_DEMO_PINNED_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/.gitignore b/.gitignore index a7f35a4b3..a3406594b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ out docs/superpowers/ .surfpool/ +node_modules/ +*.log diff --git a/Makefile b/Makefile index 172737d9a..1d4bd8554 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,55 @@ 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) + .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/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 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`. +.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 "$$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 + +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 $$( \ diff --git a/compose.mock.yml b/compose.mock.yml new file mode 100644 index 000000000..38bcec7d3 --- /dev/null +++ b/compose.mock.yml @@ -0,0 +1,46 @@ +# 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 + # 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: + - "8090:8090" + + parser_grpc_server: + image: anchorageoss-visualsign-parser/parser_grpc_server:latest + entrypoint: ["/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 + entrypoint: ["/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" + # 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: + - "8080:8080" diff --git a/compose.payai.yml b/compose.payai.yml new file mode 100644 index 000000000..add7a04ea --- /dev/null +++ b/compose.payai.yml @@ -0,0 +1,48 @@ +# 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. +# TVC_DEMO_PINNED_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 + # 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: + # Local-dev only. Production TVC provisions the real key via QoS host + # 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: + - "44020:44020" + + parser_gateway: + image: anchorageoss-visualsign-parser/parser_gateway:latest + entrypoint: ["/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}" + 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 new file mode 100644 index 000000000..3ac990a26 --- /dev/null +++ b/docs/x402-devnet-playbook.md @@ -0,0 +1,484 @@ +# 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 TVC_DEMO_PINNED_PUBKEY_HEX=$(make print-tvc-pubkey-hex) +make dev-up-payai +TVC_DEMO_PINNED_PUBKEY_HEX=$TVC_DEMO_PINNED_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 — Set the pinned TVC verifier pubkey + +The gateway must be told the enclave's expected ephemeral public key at +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 +export TVC_DEMO_PINNED_PUBKEY_HEX=$(cat src/integration/fixtures/ephemeral.pub) +``` + +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 + +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 TVC_DEMO_PINNED_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 04716208..ed68bd57 +parser_gateway dev listening on 0.0.0.0:8080 +``` + +Confirm the 402 challenge directly: + +```sh +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 -3 +``` + +Expected: `HTTP/1.1 402 Payment Required` + a `payment-required` header +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 +cd .. +export GATEWAY_URL=http://127.0.0.1:8080 +export RPC_URL=https://api.devnet.solana.com +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 ----------------------------------------------------------- +buyer address : x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW +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 +settlement: { + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "payer": "x2iWww6XjauBk83HpBMzkGPijbzy4vqdRzS5skWPxmW", + "success": true, + "transaction": "" +} + +-- Independent P256 verification ------------------------------------ +response signature verifies against pinned TVC pubkey ✓ + +-- Done ------------------------------------------------------------- +payload bytes: 734 +``` + +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 + +In another terminal: + +```sh +make dev-logs +``` + +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 `TVC_DEMO_PINNED_PUBKEY_HEX`, this is what + prevents payment for an unattested response. + +### Step 7 — Tear down + +```sh +make dev-down +``` + +### 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: 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 `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 | + +--- + +## 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 `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 +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 TVC_DEMO_PINNED_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= +TVC_DEMO_PINNED_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 \ +TVC_DEMO_PINNED_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:// \ +TVC_DEMO_PINNED_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: 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 | + +--- + +## 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/mock_facilitator/Containerfile b/images/mock_facilitator/Containerfile new file mode 100644 index 000000000..94741db16 --- /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 4c8816bb9..27d6b5dab 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/images/parser_grpc_server/Containerfile b/images/parser_grpc_server/Containerfile new file mode 100644 index 000000000..1cf3a1a43 --- /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-lock.json b/scripts/package-lock.json new file mode 100644 index 000000000..0c7e3bf0f --- /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 new file mode 100644 index 000000000..f9d8579c7 --- /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.4" + }, + "devDependencies": { + "tsx": "^4.20.0", + "typescript": "^5.6.3" + } +} diff --git a/scripts/x402-demo.sh b/scripts/x402-demo.sh new file mode 100755 index 000000000..d6804e75f --- /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, lsof, make. 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 lsof make; do + if ! command -v "$tool" >/dev/null 2>&1; then + fail "missing tool: $tool" + fi +done +ok "curl, jq, base64, cargo, lsof, make 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 | tr -d '\n' +} + +# 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 < { + 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 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, "-")); +} + +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). \`solana airdrop 2 ${buyer.publicKey.toBase58()} --url devnet\` or https://faucet.solana.com`, + ); + } + + logSection("x402 client (payai/x402-solana)"); + const client = createX402Client({ + wallet: buildWalletAdapter(buyer), + network: "solana-devnet", + rpcUrl: RPC_URL, + verbose: true, + }); + console.log("client constructed; making paid request…"); + + logSection("Paid POST /visualsign/api/v2/parse"); + const ethTx = + "0xf86c808504a817c800825208943535353535353535353535353535353535353535880de0b6b3a76400008025a028ef61340bd939bc2195fe537567866003e1a15d3c71ff63e1590620aa636276a067cbe9d8997f761aecb703304b3800ccf555c9f3dc64214b297fb1966a3b6d83"; + const resp = await client.fetch(`${GATEWAY_URL}/visualsign/api/v2/parse`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + request: { unsigned_payload: ethTx, chain: "CHAIN_ETHEREUM" }, + }), + }); + + 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 resp.text(); + if (resp.status !== 200) { + throw new Error(`paid request failed: ${resp.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; + + if (TVC_HEX) { + logSection("Independent P256 verification"); + if (sig.publicKey.toLowerCase() !== TVC_HEX) { + throw new Error( + `response pubkey ${sig.publicKey} != TVC_DEMO_PINNED_PUBKEY_HEX`, + ); + } + // qos_p256 encodes P256Public as encrypt_public || sign_public, each + // 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( + `expected 130-byte P256Public, got ${pubBytes.length}; aborting cross-check`, + ); + } + const signHalf = pubBytes.subarray(65, 130); + const digest = Buffer.from(sig.message, "hex"); + const inner = sha256(digest); + const sigBytes = Buffer.from(sig.signature, "hex"); + const ok = p256.verify(sigBytes, inner, signHalf); + 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, + ); +} + +main().catch((e) => { + console.error("FATAL:", e); + process.exitCode = 1; +}); diff --git a/src/Cargo.lock b/src/Cargo.lock index e57b6e48b..8db2db774 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -565,6 +565,7 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95d9fa2daf21f59aa546d549943f10b5cce1ae59986774019fbedae834ffe01b" dependencies = [ + "alloy-json-abi", "alloy-sol-macro-input", "const-hex", "heck 0.5.0", @@ -583,12 +584,14 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9396007fe69c26ee118a19f4dee1f5d1d6be186ea75b3881adf16d87f8444686" dependencies = [ + "alloy-json-abi", "const-hex", "dunce", "heck 0.5.0", "macro-string", "proc-macro2", "quote", + "serde_json", "syn 2.0.112", "syn-solidity", ] @@ -786,7 +789,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -797,7 +800,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1386,6 +1389,28 @@ dependencies = [ "cc", ] +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "aws-nitro-enclaves-cose" version = "0.5.2" @@ -1435,8 +1460,6 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", - "serde_json", - "serde_path_to_error", "sync_wrapper 0.1.2", "tokio", "tower 0.4.13", @@ -2214,6 +2237,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "codegen" version = "0.1.0" @@ -3281,7 +3313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3489,7 +3521,7 @@ dependencies = [ "num-bigint 0.4.6", "once_cell", "regex", - "reqwest", + "reqwest 0.12.28", "schemars 0.8.22", "serde", "serde_json", @@ -3722,6 +3754,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "funty" version = "1.1.0" @@ -4750,6 +4788,7 @@ dependencies = [ "qos_p256", "qos_test_primitives", "rand 0.9.2", + "reqwest 0.13.3", "serde", "serde_json", "sha2 0.10.9", @@ -4840,6 +4879,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.17", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.112", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -4920,7 +4989,7 @@ checksum = "7799223e0e7547b0be0b4c2afa9ab478ffbdc5eeedd06bf1d73429dda779c129" dependencies = [ "anyhow", "base64 0.22.1", - "reqwest", + "reqwest 0.12.28", "rust_decimal", "serde", "serde_json", @@ -5394,6 +5463,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mock_facilitator" +version = "0.1.0" +dependencies = [ + "axum 0.8.8", + "rand 0.8.5", + "serde", + "serde_json", + "tokio", + "tower 0.5.2", +] + [[package]] name = "move-abstract-interpreter" version = "0.1.0" @@ -5832,7 +5913,7 @@ dependencies = [ "once_cell", "parking_lot", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "snap", "sui-macros", "tempfile", @@ -6006,7 +6087,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6223,7 +6304,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 1.1.3", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", "syn 2.0.112", @@ -6570,13 +6651,29 @@ dependencies = [ name = "parser_gateway" version = "0.1.0" dependencies = [ - "axum 0.6.20", + "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", + "url", + "x402-axum", + "x402-chain-eip155", + "x402-chain-solana", + "x402-types", ] [[package]] @@ -7442,6 +7539,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "fastbloom", "getrandom 0.3.4", @@ -7451,7 +7549,7 @@ dependencies = [ "rustc-hash", "rustls 0.23.35", "rustls-pki-types", - "rustls-platform-verifier", + "rustls-platform-verifier 0.6.2", "slab", "thiserror 2.0.17", "tinyvec", @@ -7816,6 +7914,46 @@ dependencies = [ "webpki-roots 1.0.5", ] +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2 0.4.12", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.35", + "rustls-pki-types", + "rustls-platform-verifier 0.7.0", + "serde", + "serde_json", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tower 0.5.2", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest-middleware" version = "0.4.2" @@ -7825,7 +7963,7 @@ dependencies = [ "anyhow", "async-trait", "http 1.4.0", - "reqwest", + "reqwest 0.12.28", "serde", "thiserror 1.0.69", "tower-service", @@ -8067,7 +8205,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8088,6 +8226,7 @@ version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -8136,7 +8275,28 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.21.1", + "log", + "once_cell", + "rustls 0.23.35", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.8", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", "log", "once_cell", "rustls 0.23.35", @@ -8171,6 +8331,7 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -8758,6 +8919,16 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -9862,7 +10033,7 @@ dependencies = [ "crossbeam-channel", "gethostname", "log", - "reqwest", + "reqwest 0.12.28", "solana-cluster-type", "solana-sha256-hasher 2.3.0", "solana-time-utils", @@ -10445,7 +10616,7 @@ dependencies = [ "futures", "indicatif", "log", - "reqwest", + "reqwest 0.12.28", "reqwest-middleware", "semver 1.0.27", "serde", @@ -10480,7 +10651,7 @@ checksum = "2dbc138685c79d88a766a8fd825057a74ea7a21e1dd7f8de275ada899540fff7" dependencies = [ "anyhow", "jsonrpc-core", - "reqwest", + "reqwest 0.12.28", "reqwest-middleware", "serde", "serde_derive", @@ -13136,7 +13307,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -14334,9 +14505,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -14408,7 +14579,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -14754,6 +14925,70 @@ dependencies = [ "tap", ] +[[package]] +name = "x402-axum" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051cc322bb51a3e9ded2a4164e6a423411e1019401e4272593a063b7c4a97688" +dependencies = [ + "axum-core 0.5.6", + "http 1.4.0", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tower 0.5.2", + "url", + "x402-types", +] + +[[package]] +name = "x402-chain-eip155" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c292f822e5072cbb1ad9e3271f5daa9b589b065ed2321172eb307d2066681e9" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "serde", + "serde_json", + "thiserror 2.0.17", + "x402-types", +] + +[[package]] +name = "x402-chain-solana" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "477e015a9958f01c26d5e5e89cf732f0607c1798a25792cff47071995347f06f" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "solana-pubkey 4.0.0", + "thiserror 2.0.17", + "tokio", + "x402-types", +] + +[[package]] +name = "x402-types" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f3ca2463094ce34b967d51f6097dd832e251adadbf04ffb8c6241765312649" +dependencies = [ + "alloy-primitives", + "async-trait", + "base64 0.22.1", + "regex", + "rust_decimal", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.17", +] + [[package]] name = "x509-cert" version = "0.2.5" diff --git a/src/Cargo.toml b/src/Cargo.toml index d2c433263..826ccafce 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -11,6 +11,7 @@ members = [ "parser/cli", "parser/gateway", "parser/grpc-server", + "parser/mock-facilitator", "visualsign", "chain_parsers/visualsign-ethereum", "chain_parsers/visualsign-solana", diff --git a/src/integration/Cargo.toml b/src/integration/Cargo.toml index 9992d535d..8334de516 100644 --- a/src/integration/Cargo.toml +++ b/src/integration/Cargo.toml @@ -53,6 +53,9 @@ hex = { workspace = true } [dev-dependencies] visualsign-solana = { path = "../chain_parsers/visualsign-solana" } tracing = { workspace = true } +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "process", "net", "time"] } + [lints] workspace = true diff --git a/src/integration/fixtures/devnet/README.md b/src/integration/fixtures/devnet/README.md new file mode 100644 index 000000000..04edf308e --- /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 000000000..f4f626c29 --- /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 000000000..1850278d6 --- /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/tests/x402_gateway_test.rs b/src/integration/tests/x402_gateway_test.rs new file mode 100644 index 000000000..f4200b2e8 --- /dev/null +++ b/src/integration/tests/x402_gateway_test.rs @@ -0,0 +1,465 @@ +//! End-to-end: mock_facilitator + parser_grpc_server + parser_gateway, +//! exercising the v2 x402-gated route alongside the v1 open route. + +#![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; +use tokio::sync::Mutex; +use tokio::time::sleep; + +// ── 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; +/// 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 ──────────────────────────────────────────────────────────── + +fn target_bin(name: &str) -> 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)"); +} + +/// 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 +/// `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"); + qos_hex::encode(&pair.public_key().to_bytes()) +} + +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. + + // 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 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_env { + cmd.env(k, v); + } + let gateway = cmd + .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 _guard = TEST_MUTEX.lock().await; + 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 _guard = TEST_MUTEX.lock().await; + 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 _guard = TEST_MUTEX.lock().await; + 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 _guard = TEST_MUTEX.lock().await; + 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 _guard = TEST_MUTEX.lock().await; + 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); +} + +/// 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(&[("TVC_DEMO_PINNED_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/parser/gateway/Cargo.toml b/src/parser/gateway/Cargo.toml index 0430161f0..aa1a28eb4 100644 --- a/src/parser/gateway/Cargo.toml +++ b/src/parser/gateway/Cargo.toml @@ -5,13 +5,54 @@ 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" + +# 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" + +[[bin]] +name = "parser_gateway" +path = "src/main.rs" [lints] workspace = true diff --git a/src/parser/gateway/README.md b/src/parser/gateway/README.md new file mode 100644 index 000000000..6ecf5b206 --- /dev/null +++ b/src/parser/gateway/README.md @@ -0,0 +1,131 @@ +# 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) +TVC_DEMO_PINNED_PUBKEY_HEX=<260 hex chars> +# Or, equivalently: +TVC_DEMO_PINNED_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` | +| `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 + +- `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 TVC_DEMO_PINNED_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 \ +TVC_DEMO_PINNED_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 +# Offline, mock facilitator. 6 paths including signature-tamper +# detection (Path 6: pinned-pubkey mismatch -> 502 and no settlement). +make -C src test +``` + +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`. diff --git a/src/parser/gateway/src/attestation.rs b/src/parser/gateway/src/attestation.rs new file mode 100644 index 000000000..ac2e2c66c --- /dev/null +++ b/src/parser/gateway/src/attestation.rs @@ -0,0 +1,256 @@ +//! **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. +//! +//! 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; +use subtle::ConstantTimeEq; + +#[derive(Debug, thiserror::Error)] +pub enum AttestationError { + #[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_bytes: Vec, +} + +impl AttestationVerifier { + /// Production entrypoint — reads from the real process environment. + /// + /// 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> { + 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("TVC_DEMO_PINNED_PUBKEY_HEX"), + get("TVC_DEMO_PINNED_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 pinned_bytes = + qos_hex::decode(hex_value.trim()).map_err(|e| AttestationError::Hex { + field: "TVC_DEMO_PINNED_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_bytes, + }) + } + + /// 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_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); + } + + 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) + } + + /// Hex representation of the pinned key. Useful for log/error messages. + pub fn pinned_hex(&self) -> String { + qos_hex::encode(&self.pinned_bytes) + } +} + +#[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 sig = make_signed_response(&pair_b); + 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); + 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 pubkey_compare_is_case_insensitive() { + let pair = P256Pair::generate().unwrap(); + let pinned_hex = qos_hex::encode(&pair.public_key().to_bytes()); + 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/health.rs b/src/parser/gateway/src/handlers/health.rs new file mode 100644 index 000000000..59025cee9 --- /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 000000000..011597e8f --- /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 000000000..452d0723e --- /dev/null +++ b/src/parser/gateway/src/handlers/parse.rs @@ -0,0 +1,146 @@ +//! 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, + attestation, + .. + }): 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())), + ); + } + }; + + // 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::BAD_GATEWAY, + 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, + }); + + ( + 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 000000000..42307a436 --- /dev/null +++ b/src/parser/gateway/src/lib.rs @@ -0,0 +1,12 @@ +//! 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 attestation; +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 daf23f9f2..ba2b1cea5 100644 --- a/src/parser/gateway/src/main.rs +++ b/src/parser/gateway/src/main.rs @@ -4,277 +4,17 @@ #![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 parser_gateway::attestation::AttestationVerifier; 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), - } -} +use std::sync::Arc; #[tokio::main] async fn main() -> Result<(), Box> { @@ -297,27 +37,111 @@ async fn main() -> Result<(), Box> { .max_encoding_message_size(GRPC_MAX_RECV_MSG_SIZE); let health_client = HealthClient::new(channel); - let state = AppState { + // 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)) => { + 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) => { + if is_local_profile { + eprintln!( + "WARNING: TVC_DEMO_PINNED_PUBKEY_HEX not set; gateway will not attest \ + parse responses (allowed because X402_PROFILE=local)" + ); + None + } else { + eprintln!( + "FATAL: TVC_DEMO_PINNED_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 app = Router::new() - .route("/health", get(health_handler)) - .route("/visualsign/api/v1/parse", post(parse_handler)) + let mut app = Router::new() + .route( + "/health", + get(parser_gateway::handlers::health::health_handler), + ) + .route( + "/visualsign/api/v1/parse", + post(parser_gateway::handlers::parse::parse_handler), + ); + + 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); 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 +158,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 000000000..78156158d --- /dev/null +++ b/src/parser/gateway/src/state.rs @@ -0,0 +1,21 @@ +//! 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; + +#[derive(Clone)] +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/turnkey.rs b/src/parser/gateway/src/turnkey.rs new file mode 100644 index 000000000..0d1584bed --- /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 000000000..cead171c4 --- /dev/null +++ b/src/parser/gateway/src/x402_config.rs @@ -0,0 +1,626 @@ +//! 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: PriceScheme, // currently only "exact" is supported for v2 tags +} + +#[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() +} + +#[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) { + (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_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)", + 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: PriceScheme::Exact, + }) + } + + 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.parse()?, + }) + }) + .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::KnownNetworkEip155; +use x402_chain_eip155::V2Eip155Exact; +use x402_chain_eip155::chain::ChecksummedAddress; +use x402_chain_solana::KnownNetworkSolana; +use x402_chain_solana::V2SolanaExact; +use x402_chain_solana::chain::Address as SolanaAddress; +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 { + 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 + .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), + )) + } + (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!( + "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, PriceScheme::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(_))); + } + + #[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#"[ + {"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 { .. })); + } +} diff --git a/src/parser/mock-facilitator/Cargo.toml b/src/parser/mock-facilitator/Cargo.toml new file mode 100644 index 000000000..2b066d70c --- /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 000000000..432f1abf1 --- /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 000000000..0a8af6c24 --- /dev/null +++ b/src/parser/mock-facilitator/src/lib.rs @@ -0,0 +1,282 @@ +//! Mock x402 v2 facilitator — approves everything; dev/test only. + +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")] +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, +} + +/// 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 { + 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( + 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::Relaxed); + 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(), + }, + 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::Relaxed); + 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); + 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_four_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(); + 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::Relaxed), 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::Relaxed), 1); + } +} diff --git a/src/parser/mock-facilitator/src/main.rs b/src/parser/mock-facilitator/src/main.rs new file mode 100644 index 000000000..1fe990082 --- /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"); +}