diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bb41b1537c..2f38293def 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -26362,7 +26362,127 @@ { "OrbBearer": [] } - ] + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signed": { + "type": "object", + "properties": { + "payload": { + "type": "object", + "properties": { + "v": { + "type": "number", + "enum": [ + 1 + ] + }, + "ledger": { + "type": "string", + "enum": [ + "loopover.decision_ledger" + ] + }, + "seq": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "rowHash": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + }, + "totalCount": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "at": { + "type": "string", + "minLength": 1, + "maxLength": 40 + } + }, + "required": [ + "v", + "ledger", + "seq", + "rowHash", + "totalCount", + "at" + ] + }, + "keyId": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "signature": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "payload", + "keyId", + "signature" + ] + }, + "status": { + "type": "string", + "enum": [ + "ok", + "failed" + ] + }, + "backendRef": { + "type": "object", + "properties": { + "netuid": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "blockNumber": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "blockHash": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "hotkey": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "netuid", + "blockNumber", + "blockHash", + "hotkey" + ] + }, + "error": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "signed", + "status" + ] + } + } + } + } } }, "/v1/public/repos/{owner}/{repo}/proof": { diff --git a/src/openapi/internal-and-public-route-specs.ts b/src/openapi/internal-and-public-route-specs.ts index 8fc1d4f770..adb85db876 100644 --- a/src/openapi/internal-and-public-route-specs.ts +++ b/src/openapi/internal-and-public-route-specs.ts @@ -12,6 +12,7 @@ import type { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; import { z } from "zod"; import { registerRouteSpec, type RouteAuth, type RouteMethod } from "./define-route"; +import { BittensorAnchorReportRequestSchema } from "../review/ledger-anchor-bittensor"; type SpecEntry = { method: RouteMethod; @@ -20,8 +21,11 @@ type SpecEntry = { tags: [string, ...string[]]; summary: string; auth: RouteAuth; - /** Narrower path parameters than the derived string ones; only for a closed-set segment (#9707). */ - request?: { params?: z.ZodObject }; + /** Narrower path parameters than the derived string ones; only for a closed-set segment (#9707), plus the + * request body for an operation that publishes one (#9770). Both are already honoured by + * registerRouteSpec -- `body` was reachable through RouteSpecOptions but not through THIS local type, so + * an entry here could not describe its own body even though the seam knew how to render it. */ + request?: { params?: z.ZodObject; body?: z.ZodTypeAny }; /** `schema` is optional: most entries here describe a status and nothing more, but an operation that * already published a response body must not lose it on the way through the seam (#9707). */ responses: Record; @@ -492,6 +496,11 @@ const CREDENTIAL_GATED: SpecEntry[] = [ // `orb`, not `token`: the gate is LOOPOVER_LEDGER_ANCHOR_REPORT_TOKEN, an ingest bearer that is not a // LoopOver API token -- the same posture the `orb` level already exists for. auth: "orb", + // #9770: this endpoint exists to be called by a submitter this repo deliberately does not ship, so the + // OpenAPI document is its only reference -- and it published responses with no body at all, leaving the + // whole shape discoverable only by reading the TypeScript that rejects it. The schema lives beside + // parseBittensorAnchorReport, which remains the runtime authority; a cross-check test holds them together. + request: { body: BittensorAnchorReportRequestSchema }, responses: { 200: { description: "{ recorded: true, status: 'ok' | 'failed' }" }, 400: { description: "Unparseable body, or a report whose named field failed validation" }, diff --git a/src/review/ledger-anchor-bittensor.ts b/src/review/ledger-anchor-bittensor.ts index 4ba27a4c18..37b3784b33 100644 --- a/src/review/ledger-anchor-bittensor.ts +++ b/src/review/ledger-anchor-bittensor.ts @@ -19,6 +19,7 @@ // // Scope (#9277's own framing): optional, Gittensor/SN74-audience corroboration. Rekor + git remain the // default every verifier is told to check; nothing here is a required verification step. +import { z } from "zod"; import type { LedgerAnchorPayload, SignedLedgerAnchor } from "./ledger-anchor"; import { anchorKeyById, parseAnchorPublicKeys, verifyLedgerAnchorSignature } from "./ledger-anchor"; import { LEDGER_ANCHOR_LEDGER_ID, LEDGER_ANCHOR_PAYLOAD_VERSION } from "./ledger-anchor"; @@ -41,8 +42,70 @@ export type BittensorAnchorReport = { signed: SignedLedgerAnchor; } & ({ status: "ok"; backendRef: BittensorAnchorRef } | { status: "failed"; error: string }); -const HEX_32_BYTES = /^0x[0-9a-f]{64}$/i; -const ROW_HASH = /^[0-9a-f]{64}$/i; +// Case-insensitivity is spelled into the character class rather than carried as an `i` FLAG, because these +// regexes are now published as JSON Schema `pattern`s (#9770) and JSON Schema has no flags concept: a +// flagged regex serializes its source with the flag appended, so `^[0-9a-f]{64}$/i` reaches a generated +// client as a pattern requiring a literal "/i" suffix -- rejecting every valid hash. Behaviourally identical +// to the flagged form for these two inputs, and safe to publish. +const HEX_32_BYTES = /^0x[0-9a-fA-F]{64}$/; +const ROW_HASH = /^[0-9a-fA-F]{64}$/; + +/** + * The published request shape for `POST /v1/decision-ledger/anchor-attempts` (#9770). + * + * This endpoint exists to be called by a submitter this repo deliberately does NOT ship (see the module + * header): a process on the operator's own node infrastructure. Its only reference is the OpenAPI document, + * which registered responses and no body at all — so the contract's entire shape lived in the TypeScript of + * the thing rejecting it, and an implementer had to read source to discover the bounds. + * + * It lives HERE, immediately beside {@link parseBittensorAnchorReport}, rather than in the OpenAPI spec file, + * because the two are one contract described twice and must be edited together. `parseBittensorAnchorReport` + * stays the runtime authority — it is not replaced by this schema — because each of its arms returns a NAMED + * rejection naming the exact field it refused, which is what makes a submitter bug diagnosable from the 400 + * body alone; a generic zod issue list would be a downgrade for the caller this is built for. The two are + * held together by a cross-check test that runs the same corpus of payloads through both and asserts they + * agree on accept/reject, so neither can drift without failing CI. + */ +export const BittensorAnchorReportRequestSchema = z + .object({ + signed: z.object({ + payload: z.object({ + v: z.literal(LEDGER_ANCHOR_PAYLOAD_VERSION), + ledger: z.literal(LEDGER_ANCHOR_LEDGER_ID), + /** 1-based row number of the ledger tip being anchored. */ + seq: z.number().int().positive(), + /** The tip row's hash — 64 hex chars, case-insensitive. */ + rowHash: z.string().regex(ROW_HASH), + /** Total rows in the ledger at the time of signing. */ + totalCount: z.number().int().positive(), + /** When the checkpoint was signed. Bounded rather than format-validated, matching the runtime check. */ + at: z.string().min(1).max(40), + }), + /** Which published key signed this checkpoint; must appear in LOOPOVER_LEDGER_ANCHOR_KEYS. */ + keyId: z.string().min(1).max(64), + /** base64 ECDSA P-256 signature over `anchorSigningInput(payload)`. */ + signature: z.string().min(1).max(512), + }), + status: z.enum(["ok", "failed"]), + /** Required when `status` is "ok". The on-chain coordinates a verifier needs to retrieve the commitment + * from archive state, since `CommitmentOf` is overwritten in place. */ + backendRef: z + .object({ + netuid: z.number().int().min(0).max(65535), + blockNumber: z.number().int().positive(), + /** 0x-prefixed 32-byte block hash, case-insensitive; normalized to lowercase on ingest. */ + blockHash: z.string().regex(HEX_32_BYTES), + /** ss58 account of the anchor hotkey — a public on-chain identity, never key material. */ + hotkey: z.string().trim().min(1).max(64), + }) + .optional(), + /** Required when `status` is "failed". Recorded verbatim (trimmed, truncated to 500) so a submitter that + * could not commit is still visible in the public attempt log rather than silently absent. */ + error: z.string().trim().min(1).optional(), + }) + .refine((body) => (body.status === "ok" ? body.backendRef !== undefined : body.error !== undefined), { + message: 'backendRef is required when status is "ok"; error is required when status is "failed"', + }); /** Parse + bound one submitter report. Returns a typed report or a NAMED rejection reason — every arm names * the exact field it refused so a submitter bug is diagnosable from the 400 body alone. PURE. */ diff --git a/test/unit/ledger-anchor-bittensor.test.ts b/test/unit/ledger-anchor-bittensor.test.ts index a44e6776bf..e1aced45a8 100644 --- a/test/unit/ledger-anchor-bittensor.test.ts +++ b/test/unit/ledger-anchor-bittensor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + BittensorAnchorReportRequestSchema, ingestBittensorAnchorReport, parseBittensorAnchorReport, type BittensorAnchorReport, @@ -242,3 +243,66 @@ describe("#9277 routes — the submitter's two HTTP touchpoints", () => { } }); }); + +// #9770: the published request schema and the runtime validator are one contract described twice -- +// parseBittensorAnchorReport stays the authority (its per-field named rejections are what make a submitter +// bug diagnosable from the 400 body), and the zod schema exists so the OpenAPI document actually describes +// the shape a caller must send. This suite is the mechanism that stops them drifting: the SAME payloads go +// through both, and the two must agree on accept/reject. +describe("BittensorAnchorReportRequestSchema agrees with parseBittensorAnchorReport (#9770)", () => { + const okBody = () => ({ + signed: { + payload: { + v: LEDGER_ANCHOR_PAYLOAD_VERSION, + ledger: LEDGER_ANCHOR_LEDGER_ID, + seq: 7, + rowHash: "a".repeat(64), + totalCount: 7, + at: "2026-07-29T00:00:00.000Z", + }, + keyId: "k1", + signature: "c2ln", + }, + status: "ok" as const, + backendRef: { netuid: 42, blockNumber: 1234, blockHash: `0x${"b".repeat(64)}`, hotkey: "5Fabc" }, + }); + const failedBody = () => ({ signed: okBody().signed, status: "failed" as const, error: "commitment reverted" }); + + /** Every case below is run through BOTH. `accepted` is what they must agree on. */ + const cases: Array<{ name: string; body: unknown; accepted: boolean }> = [ + { name: "a well-formed ok report", body: okBody(), accepted: true }, + { name: "a well-formed failed report", body: failedBody(), accepted: true }, + { name: "an UPPERCASE rowHash / blockHash (case-insensitive by class, not by flag)", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, rowHash: "A".repeat(64) } }, backendRef: { ...okBody().backendRef, blockHash: `0x${"B".repeat(64)}` } }, accepted: true }, + { name: "a non-object body", body: "nope", accepted: false }, + { name: "a missing signed block", body: { status: "ok" }, accepted: false }, + { name: "a wrong payload version", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, v: 2 } } }, accepted: false }, + { name: "a wrong ledger id", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, ledger: "someone.else" } } }, accepted: false }, + { name: "a zero seq", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, seq: 0 } } }, accepted: false }, + { name: "a non-integer seq", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, seq: 1.5 } } }, accepted: false }, + { name: "a short rowHash", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, rowHash: "abc" } } }, accepted: false }, + { name: "an empty at", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, at: "" } } }, accepted: false }, + { name: "an over-long at", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, at: "x".repeat(41) } } }, accepted: false }, + { name: "an empty keyId", body: { ...okBody(), signed: { ...okBody().signed, keyId: "" } }, accepted: false }, + { name: "an over-long keyId", body: { ...okBody(), signed: { ...okBody().signed, keyId: "k".repeat(65) } }, accepted: false }, + { name: "an over-long signature", body: { ...okBody(), signed: { ...okBody().signed, signature: "s".repeat(513) } }, accepted: false }, + { name: "an unknown status", body: { ...okBody(), status: "maybe" }, accepted: false }, + { name: "an ok report with no backendRef", body: { signed: okBody().signed, status: "ok" }, accepted: false }, + { name: "a failed report with no error", body: { signed: okBody().signed, status: "failed" }, accepted: false }, + { name: "a failed report with a blank error", body: { ...failedBody(), error: " " }, accepted: false }, + { name: "a netuid above the u16 ceiling", body: { ...okBody(), backendRef: { ...okBody().backendRef, netuid: 65_536 } }, accepted: false }, + { name: "a negative netuid", body: { ...okBody(), backendRef: { ...okBody().backendRef, netuid: -1 } }, accepted: false }, + { name: "a zero blockNumber", body: { ...okBody(), backendRef: { ...okBody().backendRef, blockNumber: 0 } }, accepted: false }, + { name: "a blockHash missing its 0x prefix", body: { ...okBody(), backendRef: { ...okBody().backendRef, blockHash: "b".repeat(64) } }, accepted: false }, + { name: "a blank hotkey", body: { ...okBody(), backendRef: { ...okBody().backendRef, hotkey: " " } }, accepted: false }, + { name: "an over-long hotkey", body: { ...okBody(), backendRef: { ...okBody().backendRef, hotkey: "h".repeat(65) } }, accepted: false }, + ]; + + it.each(cases)("$name", ({ body, accepted }) => { + const runtime = !("error" in parseBittensorAnchorReport(body)); + const published = BittensorAnchorReportRequestSchema.safeParse(body).success; + expect(runtime).toBe(accepted); + // The load-bearing assertion: the document never promises a shape the endpoint refuses, and never + // refuses one the document promises. + expect(published).toBe(runtime); + }); +});