diff --git a/apps/loopover-ui/content/docs/what-you-can-verify.mdx b/apps/loopover-ui/content/docs/what-you-can-verify.mdx index a419e28e3..3df962efc 100644 --- a/apps/loopover-ui/content/docs/what-you-can-verify.mdx +++ b/apps/loopover-ui/content/docs/what-you-can-verify.mdx @@ -127,11 +127,28 @@ asking LoopOver anything. **Bittensor on-chain anchoring is optional, separate corroboration — not part of this default - check.** A third, Gittensor/SN74-audience-specific backend (tracked, not yet - shipped — [#9277](https://github.com/JSONbored/loopover/issues/9277)) publishes the same checkpoint - as an on-chain commitment, signed by a dedicated hotkey run on the operator's own infrastructure. - It's additive corroboration for that specific audience, never folded into the default two-backend - claim every verifier above is told to check. + check.** A third, Gittensor/SN74-audience-specific + backend ([#9277](https://github.com/JSONbored/loopover/issues/9277)) publishes the same signed + checkpoint as an on-chain commitment. A submitter on the operator's own node infrastructure — never + this Worker, and its dedicated anchor-only hotkey never leaves that infrastructure — fetches + `GET /v1/public/decision-ledger/anchor-payload` and commits `sha256(signingInput)` via the + commitments pallet's `set_commitment(netuid, Data::Sha256)`, then reports the attempt (success + *and* failure, like every other backend) back into the public attempt log, where it appears as + `backend: "bittensor"`. It's additive corroboration for that specific audience, never folded into + the default two-backend claim every verifier above is told to check. + + + + **Historical retrieval — read the block, not chain state.** The commitments pallet's + `CommitmentOf` map is **overwritten in place**: only the *latest* commitment per (netuid, account) + survives in current chain state. To verify an older anchor, use its `backendRef` from the attempt + log — `{netuid, blockNumber, blockHash, hotkey}` — and query **archive state at that block** + (`state_getStorage` at `blockHash`, or the block's events), not the current tip. Then check the + stored commitment's `Sha256` bytes equal `sha256(signingInput)` of the anchor's own + `payload_json`, and verify the payload's signature against the published anchor keys exactly as in + step (c) above. Any Bittensor archive node can answer this; the operator's own archive is merely + the convenient one — the same trust posture as the git backend's "GitHub hosts it, the mirror + cross-checks it." ### 2. Decision-record authenticity diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 4db371b43..a84890ed1 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -20766,7 +20766,8 @@ "enum": [ "rekor", "git", - "ots" + "ots", + "bittensor" ] }, "required": false, @@ -26208,6 +26209,49 @@ } } } + }, + "/v1/public/decision-ledger/anchor-payload": { + "get": { + "operationId": "getPublicDecisionLedgerAnchorPayload", + "tags": [ + "Public" + ], + "summary": "The current ledger tip as a freshly signed checkpoint, for an external anchoring submitter to commit", + "responses": { + "200": { + "description": "{ signed: { payload, keyId, signature }, signingInput } — `sha256(signingInput)` is the exact 32 bytes an on-chain commitment holds. Never cached: `payload.at` is minted per call" + }, + "404": { + "description": "Anchor signing is not configured, or the ledger is empty — nothing is claimed to be anchorable yet" + } + } + } + }, + "/v1/decision-ledger/anchor-attempts": { + "post": { + "operationId": "reportDecisionLedgerAnchorAttempt", + "tags": [ + "Public" + ], + "summary": "Report one off-Worker anchoring attempt (success or failure) into the public attempt log", + "responses": { + "200": { + "description": "{ recorded: true, status: 'ok' | 'failed' }" + }, + "400": { + "description": "Unparseable body, or a report whose named field failed validation" + }, + "401": { + "description": "Missing or wrong bearer token; also returned when no report token is configured (fails closed)" + }, + "413": { + "description": "Body exceeded the ingest ceiling" + }, + "422": { + "description": "Authenticated but unverifiable: unknown_key, bad_signature, row_not_found, or row_hash_mismatch — an `ok` report must verify against a published key AND match the live chain row" + } + } + } } }, "servers": [ diff --git a/migrations/0201_ledger_anchor_bittensor.sql b/migrations/0201_ledger_anchor_bittensor.sql new file mode 100644 index 000000000..67dd367db --- /dev/null +++ b/migrations/0201_ledger_anchor_bittensor.sql @@ -0,0 +1,26 @@ +-- #9277 (epic #9267): widen decision_ledger_anchors' backend CHECK to admit 'bittensor' — the optional, +-- Gittensor/SN74-audience on-chain commitment backend. The submission itself runs on the operator's own node +-- infrastructure (never this Worker); this table records its reported attempts, success AND failure, exactly +-- like the Rekor/git rows (#9271's whole design: a backend that can fail silently is a backend an operator +-- can silently disable). SQLite cannot ALTER a CHECK constraint, so this is the standard rebuild-and-rename. +CREATE TABLE decision_ledger_anchors_new ( + id TEXT PRIMARY KEY, + seq INTEGER NOT NULL, + row_hash TEXT NOT NULL, + payload_json TEXT NOT NULL, + signature TEXT NOT NULL, + key_id TEXT NOT NULL, + backend TEXT NOT NULL CHECK (backend IN ('rekor', 'git', 'ots', 'bittensor')), + -- For bittensor: {netuid, blockNumber, blockHash, hotkey} — deliberately the FULL historical-retrieval + -- reference: CommitmentOf is overwritten in place on-chain, so a verifier needs the block, not chain state. + backend_ref TEXT, + proof_r2_key TEXT, + status TEXT NOT NULL CHECK (status IN ('ok', 'failed')), + error TEXT, + created_at TEXT NOT NULL +); +INSERT INTO decision_ledger_anchors_new SELECT * FROM decision_ledger_anchors; +DROP TABLE decision_ledger_anchors; +ALTER TABLE decision_ledger_anchors_new RENAME TO decision_ledger_anchors; +CREATE INDEX IF NOT EXISTS decision_ledger_anchors_created_at ON decision_ledger_anchors (created_at DESC); +CREATE INDEX IF NOT EXISTS decision_ledger_anchors_backend_created_at ON decision_ledger_anchors (backend, created_at DESC); diff --git a/src/api/routes.ts b/src/api/routes.ts index 125c4f9fc..d555c395a 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -305,9 +305,10 @@ import { getContributorTrustProfile } from "../review/contributor-trust-profile" import { backfillContributorGateHistory } from "../review/contributor-gate-history-backfill"; import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride } from "../review/contributor-trust-profile-wire"; import { isRagEnabled } from "../review/rag-wire"; -import { loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record"; +import { loadDecisionLedgerTip, loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record"; import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records"; -import { currentAnchorKey, parseAnchorPublicKeys } from "../review/ledger-anchor"; +import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, signLedgerAnchorPayload } from "../review/ledger-anchor"; +import { ingestBittensorAnchorReport, parseBittensorAnchorReport } from "../review/ledger-anchor-bittensor"; import { loadPublicLedgerAnchors } from "../review/ledger-anchor-persistence"; import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats"; import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend"; @@ -1339,7 +1340,7 @@ export function createApp() { // Built with spreads, not literal undefined-valued keys: exactOptionalPropertyTypes means an optional // filter field must be OMITTED to mean "no filter", not present-with-value-undefined. const backendParam = c.req.query("backend"); - const backend = backendParam === "rekor" || backendParam === "git" || backendParam === "ots" ? backendParam : undefined; + const backend = backendParam === "rekor" || backendParam === "git" || backendParam === "ots" || backendParam === "bittensor" ? backendParam : undefined; const before = c.req.query("before"); const limit = Number(c.req.query("limit")) || undefined; const result = await loadPublicLedgerAnchors(c.env, { @@ -1351,6 +1352,48 @@ export function createApp() { return c.json(result); }); + // #9277 (epic #9267): the current tip's SIGNED checkpoint, for the operator's off-Worker Bittensor + // commitment submitter to fetch and commit on-chain (sha256 of `signingInput` is the exact 32 bytes + // `Data::Sha256` holds). Unauthenticated like every /v1/public/* sibling: it is the same payload the + // Rekor/git backends already publish externally on every checkpoint — hashes, a seq, a timestamp and a + // key id, nothing else. `no-store`: `at` is minted per call, so a cached copy would just make two + // submitters commit two different payload hashes for the same tip for no reason. + app.get("/v1/public/decision-ledger/anchor-payload", async (c) => { + const keys = parseAnchorPublicKeys(c.env.LOOPOVER_LEDGER_ANCHOR_KEYS); + const current = currentAnchorKey(keys); + if (!current || !c.env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY) return c.json({ error: "anchor_signing_unconfigured" }, 404); + const tip = await loadDecisionLedgerTip(c.env); + if (tip.seq === 0) return c.json({ error: "empty_ledger" }, 404); + const payload = buildLedgerAnchorPayload(tip, nowIso()); + const signed = await signLedgerAnchorPayload(payload, c.env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY, current.keyId); + c.header("Cache-Control", "no-store"); + return c.json({ signed, signingInput: anchorSigningInput(payload) }); + }); + + // #9277 (epic #9267): the operator's off-Worker Bittensor submitter reports each on-chain anchor attempt + // (success AND failure) back into #9271's public attempt log. Bearer-gated, FAILS CLOSED when the token is + // unset (isAuthorizedIngest, same posture as /v1/orb/ingest). Authentication alone is deliberately not + // enough for an `ok` row: the report's signed payload must verify against a PUBLISHED anchor key and its + // (seq, rowHash) must match the LIVE chain row — the public log asserting on-chain corroboration that a + // buggy submitter never actually anchored would be worse than no log at all. A `failed` report skips those + // checks: "the submitter is broken" is exactly what the attempt log exists to make publicly visible. + app.post("/v1/decision-ledger/anchor-attempts", async (c) => { + if (!(await isAuthorizedIngest(c.env.LOOPOVER_LEDGER_ANCHOR_REPORT_TOKEN, extractBearerToken(c.req.header("authorization"))))) return c.json({ error: "unauthorized" }, 401); + const body = await readOrbIngestBody(c.req.raw, c.req.header("content-length")); + if (body === null) return c.json({ error: "payload_too_large" }, 413); + let raw: unknown; + try { + raw = JSON.parse(body || ""); + } catch { + return c.json({ error: "invalid_json" }, 400); + } + const parsed = parseBittensorAnchorReport(raw); + if ("error" in parsed) return c.json({ error: "invalid_report", detail: parsed.error }, 400); + const outcome = await ingestBittensorAnchorReport(c.env, parsed.report); + if (!outcome.recorded) return c.json({ error: outcome.reason }, 422); + return c.json({ recorded: true, status: outcome.status }, 200); + }); + // #9123: the decision record itself was persisted (decision_records) but never published anywhere a // contributor or a third party could fetch the full body — the only prior public surface was // renderDecisionRecordSection's bounded review-comment summary (12-char digest prefixes, and it omits diff --git a/src/auth/route-auth.ts b/src/auth/route-auth.ts index 538675885..429036fea 100644 --- a/src/auth/route-auth.ts +++ b/src/auth/route-auth.ts @@ -48,6 +48,13 @@ export function requiresApiToken(path: string): boolean { if (path === "/v1/public/decision-ledger/anchor-key") return false; // #9271: the public anchor-attempt listing, added in the SAME PR as its route. if (path === "/v1/public/decision-ledger/anchors") return false; + // #9277: the current tip's signed checkpoint, for the operator's off-Worker Bittensor submitter (and + // anyone else — it is the same payload the Rekor/git backends already publish externally). Added in the + // SAME PR as its route, per the #9120 lesson. + if (path === "/v1/public/decision-ledger/anchor-payload") return false; + // #9277: the submitter's report route carries its OWN bearer gate (isAuthorizedIngest against + // LOOPOVER_LEDGER_ANCHOR_REPORT_TOKEN, fails closed when unset) — same posture as /v1/orb/ingest below. + if (path === "/v1/decision-ledger/anchor-attempts") return false; // #9123: the new public decision-record read route — same unauthenticated posture as its ledger-verify // sibling immediately above, added in the SAME PR so the two can never drift apart the way #9120 did. The // pull segment matches any non-slash text (not just digits): an invalid pull number is the ROUTE's 400 to diff --git a/src/env.d.ts b/src/env.d.ts index 94148c0c3..0a59667e9 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -652,6 +652,12 @@ declare global { * chokepoint every other GitHub write in this engine goes through). Unset (alongside the owner/repo * pair above) means the git backend does not run this tick; Rekor is unaffected. */ LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID?: string; + /** External ledger anchoring (#9277, epic #9267): bearer token the operator's OFF-Worker Bittensor + * commitment submitter presents to `POST /v1/decision-ledger/anchor-attempts` when reporting an + * on-chain anchor attempt back into the public attempt log. FAILS CLOSED when unset (the route + * rejects everything, same isAuthorizedIngest posture as ORB_INGEST_TOKEN) — the submitter itself and + * its hotkey live entirely on the operator's node infrastructure, never in this repo or this Worker. */ + LOOPOVER_LEDGER_ANCHOR_REPORT_TOKEN?: string; /** Convergence (port): public OAuth draft-submission flow ported from reviewbot. When truthy, the * /v1/drafts endpoints accept a contributor draft -> GitHub OAuth -> fork PR against the content repo. * Default OFF — unset/false makes every draft endpoint 404 and writes nothing (byte-identical worker). */ diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index c2c08cb89..83f03ed76 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1901,11 +1901,36 @@ export function buildOpenApiSpec() { operationId: "listPublicDecisionLedgerAnchors", tags: ["Public"], summary: "Every external anchoring attempt, success and failure, paginated newest-first — anchoring's own health as a public fact", - request: { query: z.object({ backend: z.enum(["rekor", "git", "ots"]).optional(), before: z.string().optional(), limit: z.string().optional() }) }, + request: { query: z.object({ backend: z.enum(["rekor", "git", "ots", "bittensor"]).optional(), before: z.string().optional(), limit: z.string().optional() }) }, responses: { 200: { description: "{ anchors: [{ id, seq, rowHash, keyId, backend, backendRef, status, error, createdAt }], nextBefore } — a failed attempt is returned identically to a successful one, never filtered out or reshaped" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/public/decision-ledger/anchor-payload", + operationId: "getPublicDecisionLedgerAnchorPayload", + tags: ["Public"], + summary: "The current ledger tip as a freshly signed checkpoint, for an external anchoring submitter to commit", + responses: { + 200: { description: "{ signed: { payload, keyId, signature }, signingInput } — `sha256(signingInput)` is the exact 32 bytes an on-chain commitment holds. Never cached: `payload.at` is minted per call" }, + 404: { description: "Anchor signing is not configured, or the ledger is empty — nothing is claimed to be anchorable yet" }, + }, + }); + registry.registerPath({ + method: "post", + path: "/v1/decision-ledger/anchor-attempts", + operationId: "reportDecisionLedgerAnchorAttempt", + tags: ["Public"], + summary: "Report one off-Worker anchoring attempt (success or failure) into the public attempt log", + responses: { + 200: { description: "{ recorded: true, status: 'ok' | 'failed' }" }, + 400: { description: "Unparseable body, or a report whose named field failed validation" }, + 401: { description: "Missing or wrong bearer token; also returned when no report token is configured (fails closed)" }, + 413: { description: "Body exceeded the ingest ceiling" }, + 422: { description: "Authenticated but unverifiable: unknown_key, bad_signature, row_not_found, or row_hash_mismatch — an `ok` report must verify against a published key AND match the live chain row" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/public/decision-records/{owner}/{repo}/{pull}", diff --git a/src/review/ledger-anchor-bittensor.ts b/src/review/ledger-anchor-bittensor.ts new file mode 100644 index 000000000..4ba27a4c1 --- /dev/null +++ b/src/review/ledger-anchor-bittensor.ts @@ -0,0 +1,137 @@ +// Bittensor on-chain commitment backend, repo-side glue (#9277, epic #9267). +// +// Unlike the Rekor (#9272) and git-commit (#9273) backends, the SUBMISSION never runs here: a small process +// on the operator's own node infrastructure fetches the current signed checkpoint from +// `GET /v1/public/decision-ledger/anchor-payload`, commits sha256(signingInput) on-chain via the commitments +// pallet's `set_commitment(netuid, Data::Sha256)` using a dedicated hotkey (an operational secret on that +// infrastructure, never in this repo), and then reports the outcome back through +// `POST /v1/decision-ledger/anchor-attempts`. This module is the validation boundary for that report. +// +// Why the report must be VERIFIED and not merely authenticated: the bearer token proves "the operator's +// submitter", but the attempt log is a PUBLIC trust surface (#9271's whole point). A buggy or compromised +// submitter must not be able to inject an anchor row for a payload this engine never signed, nor for a +// (seq, rowHash) pair that is not this live chain's — either would let the log claim corroboration that +// does not exist. So a report is accepted only when: +// 1. its signed payload verifies against a PUBLISHED anchor key (the same check any third party runs), and +// 2. its (seq, rowHash) matches the live ledger row at that seq (the same bind-to-chain step #9269 exists +// for) — with the one honest exception that a FAILED attempt is recorded even when signature/row checks +// would fail, because "the submitter is broken" is exactly what the public attempt log must show. +// +// 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 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"; +import { loadPublicLedgerRow } from "./decision-record"; +import { recordLedgerAnchorAttempt } from "./ledger-anchor-persistence"; + +/** The on-chain reference an `ok` report must carry — the full set a Gittensor-audience verifier needs to + * find the commitment WITHOUT trusting current chain state: `CommitmentOf` is overwritten in place, so a + * historical commitment is only reachable by querying archive state at (or the events of) this block. */ +export type BittensorAnchorRef = { + netuid: number; + blockNumber: number; + /** 0x-prefixed 32-byte block hash — the archive-state query key for historical retrieval. */ + blockHash: string; + /** ss58 account of the dedicated anchor hotkey (public on-chain identity, never key material). */ + hotkey: string; +}; + +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; + +/** 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. */ +export function parseBittensorAnchorReport(raw: unknown): { report: BittensorAnchorReport } | { error: string } { + if (!raw || typeof raw !== "object") return { error: "body must be a JSON object" }; + const o = raw as Record; + + const signedRaw = o.signed; + if (!signedRaw || typeof signedRaw !== "object") return { error: "signed: missing — fetch it from /v1/public/decision-ledger/anchor-payload" }; + const s = signedRaw as Record; + const payloadRaw = s.payload; + if (!payloadRaw || typeof payloadRaw !== "object") return { error: "signed.payload: missing" }; + const p = payloadRaw as Record; + if (p.v !== LEDGER_ANCHOR_PAYLOAD_VERSION) return { error: `signed.payload.v: expected ${LEDGER_ANCHOR_PAYLOAD_VERSION}` }; + if (p.ledger !== LEDGER_ANCHOR_LEDGER_ID) return { error: `signed.payload.ledger: expected ${LEDGER_ANCHOR_LEDGER_ID}` }; + if (typeof p.seq !== "number" || !Number.isInteger(p.seq) || p.seq <= 0) return { error: "signed.payload.seq: expected a positive integer" }; + if (typeof p.rowHash !== "string" || !ROW_HASH.test(p.rowHash)) return { error: "signed.payload.rowHash: expected 64 hex chars" }; + if (typeof p.totalCount !== "number" || !Number.isInteger(p.totalCount) || p.totalCount <= 0) return { error: "signed.payload.totalCount: expected a positive integer" }; + if (typeof p.at !== "string" || !p.at || p.at.length > 40) return { error: "signed.payload.at: expected a timestamp string" }; + const payload: LedgerAnchorPayload = { v: LEDGER_ANCHOR_PAYLOAD_VERSION, ledger: LEDGER_ANCHOR_LEDGER_ID, seq: p.seq, rowHash: p.rowHash, totalCount: p.totalCount, at: p.at }; + + if (typeof s.keyId !== "string" || !s.keyId || s.keyId.length > 64) return { error: "signed.keyId: expected a short key id" }; + if (typeof s.signature !== "string" || !s.signature || s.signature.length > 512) return { error: "signed.signature: expected base64 (<=512 chars)" }; + const signed: SignedLedgerAnchor = { payload, keyId: s.keyId, signature: s.signature }; + + if (o.status === "failed") { + if (typeof o.error !== "string" || !o.error.trim()) return { error: "error: required on a failed report" }; + return { report: { signed, status: "failed", error: o.error.trim().slice(0, 500) } }; + } + if (o.status !== "ok") return { error: 'status: expected "ok" or "failed"' }; + + const refRaw = o.backendRef; + if (!refRaw || typeof refRaw !== "object") return { error: "backendRef: required on an ok report" }; + const r = refRaw as Record; + if (typeof r.netuid !== "number" || !Number.isInteger(r.netuid) || r.netuid < 0 || r.netuid > 65535) return { error: "backendRef.netuid: expected an integer in [0, 65535]" }; + if (typeof r.blockNumber !== "number" || !Number.isInteger(r.blockNumber) || r.blockNumber <= 0) return { error: "backendRef.blockNumber: expected a positive integer" }; + if (typeof r.blockHash !== "string" || !HEX_32_BYTES.test(r.blockHash)) return { error: "backendRef.blockHash: expected 0x + 64 hex chars" }; + if (typeof r.hotkey !== "string" || !r.hotkey.trim() || r.hotkey.length > 64) return { error: "backendRef.hotkey: expected an ss58 address (<=64 chars)" }; + return { + report: { + signed, + status: "ok", + backendRef: { netuid: r.netuid, blockNumber: r.blockNumber, blockHash: r.blockHash.toLowerCase(), hotkey: r.hotkey.trim() }, + }, + }; +} + +export type BittensorReportOutcome = + | { recorded: true; status: "ok" | "failed" } + | { recorded: false; reason: "unknown_key" | "bad_signature" | "row_not_found" | "row_hash_mismatch" }; + +/** + * Verify one parsed report against the published keys and the LIVE chain, then record it in #9271's public + * attempt log with backend `bittensor` — the exact same row shape Rekor/git attempts get. + * + * A FAILED report skips the signature/row checks deliberately: it records that the submitter could not + * anchor (chain down, rate-limited, hotkey deregistered), and the payload it carries is what the submitter + * ATTEMPTED — refusing to log a failure because its evidence is imperfect would recreate exactly the + * silent-failure hole the public attempt log exists to close. An OK report asserts a public corroboration + * claim, so it must clear every check before the log will say so. + */ +export async function ingestBittensorAnchorReport(env: Env, report: BittensorAnchorReport): Promise { + if (report.status === "ok") { + const keys = parseAnchorPublicKeys(env.LOOPOVER_LEDGER_ANCHOR_KEYS); + const key = anchorKeyById(keys, report.signed.keyId); + if (!key) return { recorded: false, reason: "unknown_key" }; + if (!(await verifyLedgerAnchorSignature(report.signed, key.publicKeySpki))) return { recorded: false, reason: "bad_signature" }; + const row = await loadPublicLedgerRow(env, report.signed.payload.seq); + if (!row) return { recorded: false, reason: "row_not_found" }; + if (row.rowHash !== report.signed.payload.rowHash) return { recorded: false, reason: "row_hash_mismatch" }; + await recordLedgerAnchorAttempt(env, { + payload: report.signed.payload, + signature: report.signed.signature, + keyId: report.signed.keyId, + backend: "bittensor", + status: "ok", + backendRef: report.backendRef, + // The on-chain commitment IS the proof; there is no separate blob to store (same as git's null). + proofR2Key: null, + }); + return { recorded: true, status: "ok" }; + } + await recordLedgerAnchorAttempt(env, { + payload: report.signed.payload, + signature: report.signed.signature, + keyId: report.signed.keyId, + backend: "bittensor", + status: "failed", + error: report.error, + }); + return { recorded: true, status: "failed" }; +} diff --git a/src/review/ledger-anchor-persistence.ts b/src/review/ledger-anchor-persistence.ts index 544337ca2..fadcad634 100644 --- a/src/review/ledger-anchor-persistence.ts +++ b/src/review/ledger-anchor-persistence.ts @@ -7,7 +7,7 @@ import { nowIso, errorMessage } from "../utils/json"; import type { LedgerAnchorPayload } from "./ledger-anchor"; -export type LedgerAnchorBackend = "rekor" | "git" | "ots"; +export type LedgerAnchorBackend = "rekor" | "git" | "ots" | "bittensor"; /** What a backend passes in to record ONE attempt -- success or failure, same shape either way. */ export type LedgerAnchorAttemptInput = { diff --git a/test/unit/ledger-anchor-bittensor.test.ts b/test/unit/ledger-anchor-bittensor.test.ts new file mode 100644 index 000000000..a44e6776b --- /dev/null +++ b/test/unit/ledger-anchor-bittensor.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { + ingestBittensorAnchorReport, + parseBittensorAnchorReport, + type BittensorAnchorReport, +} from "../../src/review/ledger-anchor-bittensor"; +import { + buildLedgerAnchorPayload, + computeAnchorKeyId, + signLedgerAnchorPayload, + LEDGER_ANCHOR_LEDGER_ID, + LEDGER_ANCHOR_PAYLOAD_VERSION, + type SignedLedgerAnchor, +} from "../../src/review/ledger-anchor"; +import { appendDecisionLedger, loadDecisionLedgerTip } from "../../src/review/decision-record"; +import { loadPublicLedgerAnchors } from "../../src/review/ledger-anchor-persistence"; +import { createTestEnv } from "../helpers/d1"; +import { createApp } from "../../src/api/routes"; +import { verifyLedgerAnchorSignature, anchorKeyById, parseAnchorPublicKeys } from "../../src/review/ledger-anchor"; + +// #9277 (epic #9267): the Bittensor commitment backend's repo-side glue. The on-chain SUBMISSION runs on the +// operator's node infrastructure and is deliberately not in this repo; what IS here — and what these tests +// pin — is the validation boundary: a report only lands in the PUBLIC attempt log as `ok` when its signed +// payload verifies against a published key AND its (seq, rowHash) matches the live chain. Real ECDSA keys, +// real signatures, real D1 rows — same discipline as every sibling anchor suite. + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function toPem(base64: string, label: string): string { + return `-----BEGIN ${label}-----\n${(base64.match(/.{1,64}/g) ?? []).join("\n")}\n-----END ${label}-----`; +} + +async function generateKeypair(): Promise<{ privateKeyPem: string; publicKeySpki: string; keyId: string }> { + const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; + const pkcs8 = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("pkcs8", pair.privateKey)) as ArrayBuffer)); + const publicKeySpki = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("spki", pair.publicKey)) as ArrayBuffer)); + return { privateKeyPem: toPem(pkcs8, "PRIVATE KEY"), publicKeySpki, keyId: await computeAnchorKeyId(publicKeySpki) }; +} + +const REF = { netuid: 74, blockNumber: 5_000_001, blockHash: `0x${"ab".repeat(32)}`, hotkey: "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" }; + +/** A ledger with one real row, a published key, and a correctly signed payload for that live tip. */ +async function anchoredFixture() { + const { privateKeyPem, publicKeySpki, keyId } = await generateKeypair(); + const env = createTestEnv({ + LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([{ keyId, publicKeySpki, notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }]), + }); + await appendDecisionLedger(env, "record:o/r#1@sha1", "d".repeat(64)); + const tip = await loadDecisionLedgerTip(env); + const signed = await signLedgerAnchorPayload(buildLedgerAnchorPayload(tip, "2026-07-28T00:00:00.000Z"), privateKeyPem, keyId); + return { env, signed, privateKeyPem, keyId }; +} + +function okBody(signed: SignedLedgerAnchor): Record { + return { signed, status: "ok", backendRef: { ...REF } }; +} + +describe("parseBittensorAnchorReport (#9277)", () => { + it("accepts a complete ok report and normalizes the block hash to lowercase", async () => { + const { signed } = await anchoredFixture(); + const parsed = parseBittensorAnchorReport({ signed, status: "ok", backendRef: { ...REF, blockHash: REF.blockHash.toUpperCase().replace("0X", "0x") } }); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.report.status).toBe("ok"); + if (parsed.report.status === "ok") expect(parsed.report.backendRef.blockHash).toBe(REF.blockHash); + }); + + it("accepts a failed report with just the signed payload and an error string (bounded to 500 chars)", async () => { + const { signed } = await anchoredFixture(); + const parsed = parseBittensorAnchorReport({ signed, status: "failed", error: ` ${"e".repeat(600)} ` }); + if ("error" in parsed) throw new Error(parsed.error); + expect(parsed.report.status).toBe("failed"); + if (parsed.report.status === "failed") expect(parsed.report.error).toHaveLength(500); + }); + + it("names the exact refused field for every malformed shape — a submitter bug is diagnosable from the 400 alone", async () => { + const { signed } = await anchoredFixture(); + const p = signed.payload; + const cases: Array<[unknown, string]> = [ + [null, "JSON object"], + ["x", "JSON object"], + [{ status: "ok" }, "signed: missing"], + [{ signed: "x", status: "ok" }, "signed: missing"], + [{ signed: {}, status: "ok" }, "signed.payload: missing"], + [{ signed: { payload: { ...p, v: 99 } }, status: "ok" }, "signed.payload.v"], + [{ signed: { payload: { ...p, ledger: "other" } }, status: "ok" }, "signed.payload.ledger"], + [{ signed: { payload: { ...p, seq: 0 } }, status: "ok" }, "signed.payload.seq"], + [{ signed: { payload: { ...p, seq: 1.5 } }, status: "ok" }, "signed.payload.seq"], + [{ signed: { payload: { ...p, rowHash: "xyz" } }, status: "ok" }, "signed.payload.rowHash"], + [{ signed: { payload: { ...p, totalCount: 0 } }, status: "ok" }, "signed.payload.totalCount"], + [{ signed: { payload: { ...p, at: "" } }, status: "ok" }, "signed.payload.at"], + [{ signed: { payload: p, keyId: "" }, status: "ok" }, "signed.keyId"], + [{ signed: { payload: p, keyId: "k", signature: "x".repeat(513) }, status: "ok" }, "signed.signature"], + [{ signed: { payload: p, keyId: "k", signature: "sig" }, status: "maybe" }, "status"], + [{ signed: { payload: p, keyId: "k", signature: "sig" }, status: "failed" }, "error: required"], + [{ signed: { payload: p, keyId: "k", signature: "sig" }, status: "failed", error: " " }, "error: required"], + [{ signed: { payload: p, keyId: "k", signature: "sig" }, status: "ok" }, "backendRef: required"], + [{ ...okBody(signed), backendRef: { ...REF, netuid: -1 } }, "backendRef.netuid"], + [{ ...okBody(signed), backendRef: { ...REF, netuid: 65536 } }, "backendRef.netuid"], + [{ ...okBody(signed), backendRef: { ...REF, blockNumber: 0 } }, "backendRef.blockNumber"], + [{ ...okBody(signed), backendRef: { ...REF, blockHash: "0x123" } }, "backendRef.blockHash"], + [{ ...okBody(signed), backendRef: { ...REF, hotkey: "" } }, "backendRef.hotkey"], + [{ ...okBody(signed), backendRef: { ...REF, hotkey: "h".repeat(65) } }, "backendRef.hotkey"], + ]; + for (const [raw, expected] of cases) { + const parsed = parseBittensorAnchorReport(raw); + expect("error" in parsed, JSON.stringify(raw).slice(0, 80)).toBe(true); + if ("error" in parsed) expect(parsed.error).toContain(expected); + } + }); + + it("netuid 0 (the root subnet) is a valid netuid — the floor is 0, not 1", async () => { + const { signed } = await anchoredFixture(); + const parsed = parseBittensorAnchorReport({ ...okBody(signed), backendRef: { ...REF, netuid: 0 } }); + expect("report" in parsed).toBe(true); + }); +}); + +describe("ingestBittensorAnchorReport (#9277) — the verification boundary the public log sits behind", () => { + it("records a fully verified ok report, and it appears in the PUBLIC attempt log as backend=bittensor", async () => { + const { env, signed } = await anchoredFixture(); + const report: BittensorAnchorReport = { signed, status: "ok", backendRef: { ...REF } }; + expect(await ingestBittensorAnchorReport(env, report)).toEqual({ recorded: true, status: "ok" }); + const listed = await loadPublicLedgerAnchors(env, { backend: "bittensor" }); + expect(listed.anchors).toHaveLength(1); + expect(listed.anchors[0]).toMatchObject({ backend: "bittensor", status: "ok", seq: signed.payload.seq, rowHash: signed.payload.rowHash, backendRef: REF }); + }); + + it("REGRESSION: an ok report signed by an UNPUBLISHED key is refused — a bearer token alone cannot forge corroboration", async () => { + const { env, signed } = await anchoredFixture(); + const rogue = await generateKeypair(); + const forged = await signLedgerAnchorPayload(signed.payload, rogue.privateKeyPem, rogue.keyId); + expect(await ingestBittensorAnchorReport(env, { signed: forged, status: "ok", backendRef: { ...REF } })).toEqual({ recorded: false, reason: "unknown_key" }); + // Claiming the PUBLISHED keyId over a rogue signature fails the signature check instead. + const impersonating = { ...forged, keyId: signed.keyId }; + expect(await ingestBittensorAnchorReport(env, { signed: impersonating, status: "ok", backendRef: { ...REF } })).toEqual({ recorded: false, reason: "bad_signature" }); + expect((await loadPublicLedgerAnchors(env, { backend: "bittensor" })).anchors).toHaveLength(0); + }); + + it("REGRESSION: an ok report is bound to the LIVE chain — a wrong seq or a re-chained rowHash is refused", async () => { + const { env, signed, privateKeyPem, keyId } = await anchoredFixture(); + // Seq beyond the live tip: no row to bind to. + const beyond = await signLedgerAnchorPayload({ ...signed.payload, seq: 999 }, privateKeyPem, keyId); + expect(await ingestBittensorAnchorReport(env, { signed: beyond, status: "ok", backendRef: { ...REF } })).toEqual({ recorded: false, reason: "row_not_found" }); + // Right seq, wrong hash: exactly what anchoring exists to catch — a validly signed payload for a hash + // that is not THIS chain's row must never enter the log as corroboration. + const rechained = await signLedgerAnchorPayload({ ...signed.payload, rowHash: "b".repeat(64) }, privateKeyPem, keyId); + expect(await ingestBittensorAnchorReport(env, { signed: rechained, status: "ok", backendRef: { ...REF } })).toEqual({ recorded: false, reason: "row_hash_mismatch" }); + }); + + it("INVARIANT: a FAILED report records without signature/row verification — a broken submitter must be publicly visible, not silently unloggable", async () => { + const { env, signed } = await anchoredFixture(); + const rogue = await generateKeypair(); + const unverifiable = await signLedgerAnchorPayload(signed.payload, rogue.privateKeyPem, rogue.keyId); + expect(await ingestBittensorAnchorReport(env, { signed: unverifiable, status: "failed", error: "subtensor RPC timeout after 3 attempts" })).toEqual({ recorded: true, status: "failed" }); + const listed = await loadPublicLedgerAnchors(env, { backend: "bittensor" }); + expect(listed.anchors).toHaveLength(1); + expect(listed.anchors[0]).toMatchObject({ backend: "bittensor", status: "failed", error: "subtensor RPC timeout after 3 attempts", backendRef: null }); + }); + + it("payload constants round-trip: the fixture's payload is exactly the shape the parser demands", async () => { + const { signed } = await anchoredFixture(); + expect(signed.payload.v).toBe(LEDGER_ANCHOR_PAYLOAD_VERSION); + expect(signed.payload.ledger).toBe(LEDGER_ANCHOR_LEDGER_ID); + const parsed = parseBittensorAnchorReport(okBody(signed)); + expect("report" in parsed).toBe(true); + }); +}); + +describe("#9277 routes — the submitter's two HTTP touchpoints", () => { + it("GET anchor-payload serves a freshly signed checkpoint an outside verifier can check, and degrades honestly", async () => { + const app = createApp(); + const { privateKeyPem, publicKeySpki, keyId } = await generateKeypair(); + // Unconfigured signing: honest 404, not a crash and not an unsigned payload. + const bare = createTestEnv(); + expect((await app.request("/v1/public/decision-ledger/anchor-payload", {}, bare)).status).toBe(404); + // Configured but EMPTY ledger: nothing to anchor yet. + const env = createTestEnv({ + LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([{ keyId, publicKeySpki, notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }]), + LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: privateKeyPem, + }); + expect((await app.request("/v1/public/decision-ledger/anchor-payload", {}, env)).status).toBe(404); + // With a real row: unauthenticated 200 whose signature verifies against the PUBLISHED key — the exact + // check the off-Worker submitter (or anyone) runs before committing the hash on-chain. + await appendDecisionLedger(env, "record:o/r#1@sha1", "d".repeat(64)); + const res = await app.request("/v1/public/decision-ledger/anchor-payload", {}, env); + expect(res.status).toBe(200); + expect(res.headers.get("cache-control")).toBe("no-store"); + const body = (await res.json()) as { signed: SignedLedgerAnchor; signingInput: string }; + expect(body.signed.payload.seq).toBe(1); + expect(body.signingInput).toContain(LEDGER_ANCHOR_LEDGER_ID); + const key = anchorKeyById(parseAnchorPublicKeys(env.LOOPOVER_LEDGER_ANCHOR_KEYS), body.signed.keyId); + expect(key && (await verifyLedgerAnchorSignature(body.signed, key.publicKeySpki))).toBe(true); + }); + + it("POST anchor-attempts FAILS CLOSED with no token configured, rejects a wrong bearer, and accepts a verified report end-to-end", async () => { + const app = createApp(); + const { env, signed } = await anchoredFixture(); + const post = (target: Env, headers: Record, body: unknown) => + app.request("/v1/decision-ledger/anchor-attempts", { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json", ...headers } }, target); + // Unset token: rejected — an unconfigured collector never accepts anonymous writes. + expect((await post(env, {}, okBody(signed))).status).toBe(401); + (env as { LOOPOVER_LEDGER_ANCHOR_REPORT_TOKEN?: string }).LOOPOVER_LEDGER_ANCHOR_REPORT_TOKEN = "s3cret"; + expect((await post(env, { authorization: "Bearer nope" }, okBody(signed))).status).toBe(401); + // An oversize body is refused at the byte ceiling before any parsing. + const oversize = await app.request( + "/v1/decision-ledger/anchor-attempts", + { method: "POST", body: "x", headers: { "content-type": "application/json", authorization: "Bearer s3cret", "content-length": "2097152" } }, + env, + ); + expect(oversize.status).toBe(413); + // Malformed JSON and malformed reports are the route's own 400s, with the parser's named reason. + const badJson = await app.request("/v1/decision-ledger/anchor-attempts", { method: "POST", body: "{nope", headers: { "content-type": "application/json", authorization: "Bearer s3cret" } }, env); + expect(badJson.status).toBe(400); + // An EMPTY body is invalid JSON too, not a crash on undefined. + const emptyBody = await app.request("/v1/decision-ledger/anchor-attempts", { method: "POST", headers: { authorization: "Bearer s3cret" } }, env); + expect(emptyBody.status).toBe(400); + const badReport = await post(env, { authorization: "Bearer s3cret" }, { status: "ok" }); + expect(badReport.status).toBe(400); + expect(((await badReport.json()) as { detail: string }).detail).toContain("signed: missing"); + // An authenticated but UNVERIFIABLE ok report is 422 with the named reason — never recorded. + const rogue = await generateKeypair(); + const forged = await signLedgerAnchorPayload(signed.payload, rogue.privateKeyPem, rogue.keyId); + const refused = await post(env, { authorization: "Bearer s3cret" }, okBody(forged)); + expect(refused.status).toBe(422); + expect(((await refused.json()) as { error: string }).error).toBe("unknown_key"); + // The genuine report lands, and the PUBLIC listing (filter widened to bittensor) serves it. + const accepted = await post(env, { authorization: "Bearer s3cret" }, okBody(signed)); + expect(accepted.status).toBe(200); + expect(await accepted.json()).toEqual({ recorded: true, status: "ok" }); + const listing = await app.request("/v1/public/decision-ledger/anchors?backend=bittensor", {}, env); + const listed = (await listing.json()) as { anchors: Array<{ backend: string; status: string }> }; + expect(listed.anchors).toHaveLength(1); + expect(listed.anchors[0]).toMatchObject({ backend: "bittensor", status: "ok" }); + // The widened filter keeps every sibling arm intact: named backends still filter, junk still means "all". + for (const [query, expected] of [["?backend=rekor", 0], ["?backend=git", 0], ["?backend=ots", 0], ["?backend=nonsense", 1], ["", 1]] as const) { + const page = await app.request(`/v1/public/decision-ledger/anchors${query}`, {}, env); + expect(((await page.json()) as { anchors: unknown[] }).anchors).toHaveLength(expected); + } + }); +});