diff --git a/README.md b/README.md index 5018f3c..e33527b 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ When a verb request omits `x402`, the runtime fabricates defaults from the live When `VERIFY_SCHEMA_CACHED_ONLY=1` (the default), `/verify?schema=1` returns HTTP `202` with `validator_not_warmed_yet` if the validator for that verb has not been compiled yet. `POST /debug/prewarm` can queue validator warmup, and `GET /debug/validators` shows cache state. +If `/verify` exceeds `VERIFY_MAX_MS`, the runtime returns HTTP `502` with `failure_type: "availability"`, `retryable: true`, and the message `Verification service did not respond. Receipt may still be valid; retry recommended.` Clients should treat that as transient service unavailability, not a cryptographic proof failure. + ## ENS verification inputs When `ens=1`, the runtime resolves TXT records directly on the signer ENS name and reads: diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 2a8c43d..8c159b7 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -144,6 +144,12 @@ This is expected when: Use `/debug/prewarm`, retry later, or disable cached-only mode. +### `/verify` returns HTTP `502` + +This indicates a transient verify availability failure, not proof invalidity. The runtime emits `failure_type: "availability"`, `retryable: true`, and a retry-oriented message so callers can distinguish service timeouts from cryptographic failures. + +Given the current server implementation, this points more strongly to process stall/crash or an upstream proxy timeout than to cold schema loading alone, because cold validator loading under cached-only mode returns HTTP `202` instead of timing out. If the request used `ens=1&refresh=1` or `schema=1` with cached-only mode disabled, blocked upstream fetches or ENS/schema slowness are also plausible contributors. + ### ENS verification fails before signature checks Check: diff --git a/runtime/tests/runtime-signing.test.mjs b/runtime/tests/runtime-signing.test.mjs index ecbb2fb..52ff4fe 100644 --- a/runtime/tests/runtime-signing.test.mjs +++ b/runtime/tests/runtime-signing.test.mjs @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync, spawn } from "node:child_process"; +import http from "node:http"; import net from "node:net"; import { generateKeyPairSync, createHash } from "node:crypto"; @@ -246,6 +247,48 @@ test("/verify?ens=1&strict_kid=1 rejects a receipt when ENS publishes a differen } }); +test("/verify surfaces transient timeout failures separately from cryptographic failures", async () => { + const keys = makeKeys(); + const rpcPort = await freePort(); + const rpcHang = http.createServer((req, res) => { + req.on("data", () => {}); + req.on("end", () => {}); + }); + await new Promise((resolve) => rpcHang.listen(rpcPort, "127.0.0.1", resolve)); + + const srv = await startServer({ + VERIFY_MAX_MS: "50", + RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64: keys.privatePemB64, + RECEIPT_SIGNING_PUBLIC_KEY_B64: keys.publicRaw32B64, + RECEIPT_SIGNER_ID: "runtime.commandlayer.eth", + ETH_RPC_URL: `http://127.0.0.1:${rpcPort}`, + }); + + try { + const response = await createDescribeReceipt(srv.base); + + const verifyResp = await fetch(`${srv.base}/verify?ens=1&refresh=1`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(response), + }); + const verifyJson = await verifyResp.json(); + + assert.equal(verifyResp.status, 502); + assert.equal(verifyJson.ok, false); + assert.equal(verifyJson.failure_type, "availability"); + assert.equal(verifyJson.retryable, true); + assert.equal(verifyJson.reason, "verify_service_unavailable"); + assert.match(String(verifyJson.message), /Receipt may still be valid; retry recommended\./); + assert.equal(verifyJson.checks?.hash_matches, null); + assert.equal(verifyJson.checks?.signature_valid, null); + assert.equal(verifyJson.checks?.ens_match, null); + } finally { + await stop(srv.proc); + await new Promise((resolve, reject) => rpcHang.close((err) => (err ? reject(err) : resolve()))); + } +}); + test("fabricated x402 defaults use v1.1.0", async () => { const keys = makeKeys(); const srv = await startServer({ diff --git a/server.mjs b/server.mjs index 3dc42a4..3c398cc 100644 --- a/server.mjs +++ b/server.mjs @@ -1801,10 +1801,31 @@ app.post("/verify", async (req, res) => { new Promise((_, rej) => setTimeout(() => rej(new Error("verify_timeout")), VERIFY_MAX_MS)), ]); } catch (e) { - return res.status(500).json({ + const errorMessage = e?.message || "verify failed"; + const transient = errorMessage === "verify_timeout"; + + if (transient) { + console.error("[verify] transient availability failure", { + error: errorMessage, + want_ens: wantEns, + want_schema: wantSchema, + refresh, + strict_kid: strictKid, + verb: receipt?.x402?.verb ?? null, + signer_id: proof?.signer_id ?? null, + }); + } + + return res.status(transient ? 502 : 500).json({ ok: false, - error: e?.message || "verify failed", - checks: { schema_valid: null, hash_matches: null, signature_valid: false, ens_match: null }, + error: errorMessage, + failure_type: transient ? "availability" : "application", + retryable: transient, + message: transient + ? "Verification service did not respond. Receipt may still be valid; retry recommended." + : "Verification failed.", + reason: transient ? "verify_service_unavailable" : "verify_failed", + checks: { schema_valid: null, hash_matches: null, signature_valid: null, ens_match: null }, ...instancePayload(), }); }