diff --git a/packages/agent/src/discovery.ts b/packages/agent/src/discovery.ts index ddeffd456..b994aa423 100644 --- a/packages/agent/src/discovery.ts +++ b/packages/agent/src/discovery.ts @@ -93,6 +93,31 @@ export class DiscoveryClient { })); } + /** + * OT-RFC-55 §5.1 — the READ side of the "context-oracle" phonebook: which peers + * advertise that they serve the (public) context graph `contextGraphId`. Reads + * the `skill:contextGraphsServed` triples published by {@link buildAgentProfile} + * into the agents registry CG. Only public, subscribed, non-system CGs are ever + * advertised there (the publish side filters), so this never leaks private CGs. + * + * Strictly local (over this node's synced copy of the agents CG), so coverage is + * bounded by agents-CG gossip freshness — same caveat as {@link findAgents}. + */ + async findNodesServingCG(contextGraphId: string): Promise { + const sparql = ` + SELECT DISTINCT ?peerId WHERE { + ?agent <${DKG}peerId> ?peerId ; + <${SKILL}hostingProfile> ?hosting . + ?hosting <${SKILL}contextGraphsServed> "${escapeSparqlLiteral(contextGraphId)}" . + } + `; + const result = await this.engine.query(sparql, { contextGraphId: AGENT_REGISTRY_CONTEXT_GRAPH }); + const peers = result.bindings + .map((row) => stripQuotes(row['peerId'])) + .filter((p): p is string => typeof p === 'string' && p.length > 0); + return Array.from(new Set(peers)); + } + async findSkillOfferings(options: SkillSearchOptions = {}): Promise { const filters: string[] = []; diff --git a/packages/agent/src/dkg-agent-drag.ts b/packages/agent/src/dkg-agent-drag.ts new file mode 100644 index 000000000..bcb4a243a --- /dev/null +++ b/packages/agent/src/dkg-agent-drag.ts @@ -0,0 +1,626 @@ +/** + * dRAG answer methods (OT-RFC-55 P2 — single-node `dkg_answer`). + * + * `dragAnswerLocal` turns a natural-language question into a grounded, CITED + * answer over the verifiable-memory of one Context Graph held on THIS node: + * + * question → keyword retrieval over per-KA VM graphs → canonical triples → + * a {@link VerifiableCitation} per cited fact (Merkle + on-chain + author seal). + * + * V1 retrieval is KEYWORD/STRUCTURAL — no LLM is required, which is the + * demoable baseline (LLM synthesis is an optional enhancement, gated on a + * configured model; see `llm` in the result). The headline guarantee is the + * audit trail: every fact in the answer is bound to a sealed, on-chain-anchored + * Knowledge Asset the caller can independently verify. + */ + +import { DKGAgentBase } from './dkg-agent-base.js'; +import type { DKGAgent } from './dkg-agent.js'; +import { + prepareKaCitation, + citeTriple, + verifyVerifiableCitation, + type CitationChainReads, + type PreparedKaCitation, +} from './drag/citation.js'; +import { PROTOCOL_DRAG_ANSWER, validateContextGraphId } from '@origintrail-official/dkg-core'; +import type { VerifiableCitation, CitationTriple, CitationChecks } from '@origintrail-official/dkg-core'; + +/** Per-KA VM graph: `…/_verifiable_memory//` → {author, number}. */ +const VM_GRAPH_RE = /\/_verifiable_memory\/(0x[0-9a-fA-F]{40})\/(\d+)$/; + +const STOPWORDS = new Set([ + 'the', 'a', 'an', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', + 'as', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'this', 'that', 'these', 'those', + 'it', 'its', 'they', 'them', 'their', 'which', 'who', 'whom', 'what', 'when', 'where', 'why', + 'how', 'do', 'does', 'did', 'has', 'have', 'had', 'can', 'could', 'should', 'would', 'will', + 'about', 'into', 'from', 'me', 'my', 'we', 'our', 'you', 'your', 'any', 'all', 'some', +]); + +export interface DragFact { + subject: string; + predicate: string; + object: string; + /** Index (1-based) of the source KA in the answer's Sources list. */ + source: number; +} + +export interface DragAnswerResult { + question: string; + contextGraphId: string; + scope: 'local'; + /** Human-readable, grounded answer (keyword/structural; no LLM in V1). */ + answer: string; + /** Whether an LLM synthesised the prose (false in the keyword baseline). */ + llm: boolean; + /** Per-fact verifiable citations — independently auditable against the chain. */ + citations: VerifiableCitation[]; + facts: DragFact[]; + stats: { keywords: string[]; kasMatched: number; factsCited: number; verified: number }; +} + +export interface DragPerNode { + peerId: string; + /** Facts the node returned (before dedup). */ + factsCited: number; + /** Of those, how many the ASKER re-verified against the chain. */ + verified: number; + error?: string; +} + +export interface DragNetworkAnswerResult { + question: string; + contextGraphId: string; + scope: 'network'; + answer: string; + llm: boolean; + /** Deduped citations, each RE-VERIFIED by the asker against its own chain. */ + citations: VerifiableCitation[]; + facts: DragFact[]; + perNode: DragPerNode[]; + stats: { keywords: string[]; servingNodes: number; nodesAnswered: number; factsCited: number; verified: number }; +} + +/** Extract content keywords from a question (lowercase, drop stopwords + short tokens). */ +export function extractKeywords(question: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const raw of question.toLowerCase().split(/[^a-z0-9]+/)) { + const tok = raw.trim(); + if (tok.length < 3 || STOPWORDS.has(tok) || seen.has(tok)) continue; + seen.add(tok); + out.push(tok); + if (out.length >= 12) break; + } + return out; +} + +/** Strip a namespace prefix for compact display (last path/hash segment). */ +function shortPredicate(p: string): string { + const m = p.match(/[/#]([^/#]+)$/); + return m ? m[1] : p; +} + +/** Strip N-Triples literal quoting / datatype / lang for display. */ +function displayObject(o: string): string { + const lit = o.match(/^"((?:[^"\\]|\\.)*)"(?:\^\^.*|@.*)?$/); + if (lit) return lit[1].replace(/\\"/g, '"'); + const iri = o.match(/^<(.+)>$/); + return iri ? iri[1] : o; +} + +function shortKa(kaId: string): string { + // kaId = (author<<96)|number — show author tail + number. + try { + const v = BigInt(kaId); + const author = '0x' + (v >> 96n).toString(16).padStart(40, '0'); + const number = (v & ((1n << 96n) - 1n)).toString(); + return `${author.slice(0, 6)}…${author.slice(-4)}/${number}`; + } catch { + return kaId.slice(0, 12); + } +} + +function shortHex(h: string): string { + return h.length > 14 ? `${h.slice(0, 8)}…${h.slice(-4)}` : h; +} + +function flag(v: boolean | null): string { + if (v === true) return '✓'; + if (v === false) return '✗'; + return '~'; // null = trusted-via-chain but not independently re-derived +} + +function objectMatchesKeyword(object: string, keywords: string[]): boolean { + if (!object.startsWith('"')) return false; // literals only + const lc = object.toLowerCase(); + return keywords.some((kw) => lc.includes(kw)); +} + +export class DragMethods extends DKGAgentBase { + /** + * Answer `question` over one Context Graph's verifiable memory on THIS node, + * returning a grounded answer with a verifiable citation per cited fact. + */ + async dragAnswerLocal( + this: DKGAgent, + args: { + question: string; + contextGraphId: string; // CG name + maxCitations?: number; + maxKas?: number; + }, + ): Promise { + const maxCitations = Math.min(Math.max(args.maxCitations ?? 12, 1), 50); + const maxKas = Math.min(Math.max(args.maxKas ?? 25, 1), 100); + const keywords = extractKeywords(args.question); + + const empty = (note: string): DragAnswerResult => ({ + question: args.question, + contextGraphId: args.contextGraphId, + scope: 'local', + answer: note, + llm: false, + citations: [], + facts: [], + stats: { keywords, kasMatched: 0, factsCited: 0, verified: 0 }, + }); + + if (keywords.length === 0) { + return empty('No searchable keywords in the question.'); + } + + // Validate the CG id before it is ever interpolated into a SPARQL literal + // (defense-in-depth + parity with the query routes). + const idCheck = validateContextGraphId(args.contextGraphId); + if (!idCheck.valid) { + return empty(`Invalid context graph id: ${idCheck.reason ?? 'rejected'}.`); + } + + // Resolve the CG's on-chain numeric id (needed by the KA extractor). + const onChainIdStr = await this.getContextGraphOnChainId(args.contextGraphId).catch(() => null); + if (!onChainIdStr || !/^\d+$/.test(onChainIdStr) || BigInt(onChainIdStr) === 0n) { + return empty( + `Context graph "${args.contextGraphId}" is not registered on-chain — verifiable citations require an anchored CG.`, + ); + } + const cgOnChainId = BigInt(onChainIdStr); + + // 1. Find candidate per-KA VM graphs whose public literals match a keyword. + const vmPrefix = `did:dkg:context-graph:${args.contextGraphId}/_verifiable_memory/`; + const kwFilter = keywords + .map((kw) => `CONTAINS(LCASE(STR(?o)), "${kw.replace(/["\\]/g, '')}")`) + .join(' || '); + const sparql = `SELECT DISTINCT ?g WHERE { + GRAPH ?g { + ?s ?p ?o . + FILTER(isLiteral(?o)) + FILTER(${kwFilter}) + } + FILTER(STRSTARTS(STR(?g), "${vmPrefix}")) + } LIMIT ${maxKas}`; + + const result = await this.store.query(sparql); + const graphs = + result.type === 'bindings' + ? result.bindings.map((b) => (b['g'] ?? '').replace(/^<|>$/g, '')).filter(Boolean) + : []; + + if (graphs.length === 0) { + return empty(`No verifiable facts found for: ${keywords.join(', ')}.`); + } + + // 2. Per candidate KA: prepare once, cite matching canonical triples. + const chain: CitationChainReads = { + getLatestMerkleRoot: (kaId) => this.chain.getLatestMerkleRoot!(kaId), + getMerkleLeafCount: (kaId) => this.chain.getMerkleLeafCount!(kaId), + getLatestMerkleRootAuthor: (kaId) => this.chain.getLatestMerkleRootAuthor!(kaId), + }; + const chainId = await this.chain.getEvmChainId().catch(() => 0n); + const deps = { store: this.store, chain, servingNode: this.peerId ?? 'local', chainId }; + + const citations: VerifiableCitation[] = []; + const facts: DragFact[] = []; + const kaIndex = new Map(); // kaId -> 1-based source index + let kasMatched = 0; + + for (const g of graphs) { + if (citations.length >= maxCitations) break; + const m = g.match(VM_GRAPH_RE); + if (!m) continue; + const author = m[1]; + const number = BigInt(m[2]); + const kaId = (BigInt(author) << 96n) | number; + + let prepared: PreparedKaCitation; + try { + prepared = await prepareKaCitation(deps, { contextGraphId: cgOnChainId, kaId }); + } catch { + continue; // KA not fully synced / not anchored — skip, don't fabricate + } + kasMatched++; + + const matched = prepared.triples.filter((t) => objectMatchesKeyword(t.object, keywords)); + for (const triple of matched) { + if (citations.length >= maxCitations) break; + let citation: VerifiableCitation; + try { + citation = citeTriple(prepared, triple as CitationTriple); + } catch { + continue; + } + let idx = kaIndex.get(citation.kaId); + if (idx === undefined) { + idx = kaIndex.size + 1; + kaIndex.set(citation.kaId, idx); + } + citations.push(citation); + facts.push({ subject: triple.subject, predicate: triple.predicate, object: triple.object, source: idx }); + } + } + + const verified = citations.filter((c) => c.checks.verified).length; + const answer = renderAnswer(args.question, facts, citations, kaIndex); + + return { + question: args.question, + contextGraphId: args.contextGraphId, + scope: 'local', + answer, + llm: false, + citations, + facts, + stats: { keywords, kasMatched, factsCited: citations.length, verified }, + }; + } + + /** + * Ask ONE peer to answer `question` over a public context graph it serves. + * JSON over the dRAG-answer libp2p protocol; the peer runs its own + * `dragAnswerLocal` and returns grounded, verifiable citations. + */ + async dragAnswerRemote( + this: DKGAgent, + peerId: string, + args: { question: string; contextGraphId: string; maxCitations?: number; maxKas?: number }, + ): Promise { + const payload = new TextEncoder().encode( + JSON.stringify({ + question: args.question, + contextGraphId: args.contextGraphId, + maxCitations: args.maxCitations, + maxKas: args.maxKas, + }), + ); + const sendResult = await this.messenger.sendReliable(peerId, PROTOCOL_DRAG_ANSWER, payload); + if (!sendResult.delivered) { + throw new Error(`dRAG remote to ${peerId.slice(-8)} not delivered (queued): ${sendResult.error ?? 'unknown'}`); + } + const decoded: unknown = JSON.parse(new TextDecoder().decode(sendResult.response)); + if (decoded && typeof decoded === 'object' && 'error' in decoded) { + throw new Error(`peer ${peerId.slice(-8)}: ${String((decoded as { error: unknown }).error)}`); + } + // Validate the shape up front so a single malformed / version-skewed peer + // cannot throw deep in the aggregator and discard every honest peer's + // citations — a bad response becomes a clean per-node error instead. + if (!isValidDragAnswerResult(decoded)) { + throw new Error(`peer ${peerId.slice(-8)} returned a malformed dRAG response`); + } + return decoded; + } + + /** + * Answer `question` over a PUBLIC context graph by fanning out across the peers + * that serve it (resolved from the agents-CG phonebook), aggregating their + * grounded citations, and — crucially — RE-VERIFYING every citation against + * THIS node's own chain. The asker therefore trusts no serving node's + * self-reported verdict; a fabricated or tampered citation fails the asker's + * independent check. The asker need not hold the CG itself. + */ + async dragAnswerNetwork( + this: DKGAgent, + args: { + question: string; + contextGraphId: string; + maxCitations?: number; + maxKas?: number; + /** Also answer locally if this node serves the CG (default: auto-detect). */ + includeSelf?: boolean; + /** + * Explicit serving peerIds to fan out to, UNION'd with the phonebook + * discovery. Useful when the caller already knows serving nodes, or when + * an advertisement has not yet gossiped into this node's agents-CG (the + * phonebook integrates fresh `contextGraphsServed` updates on a heartbeat + * cadence, so discovery can lag a just-created CG). + */ + peers?: string[]; + }, + ): Promise { + const keywords = extractKeywords(args.question); + // Bind the answer to the asked CG: only credit a citation whose KA belongs + // to THIS on-chain CG — defends the cross-CG scope-swap (a peer could + // otherwise return a genuinely-verifiable fact drawn from a DIFFERENT KA). + const askedCgIdStr = await this.getContextGraphOnChainId(args.contextGraphId).catch(() => null); + const askedCgId = + askedCgIdStr && /^\d+$/.test(askedCgIdStr) && BigInt(askedCgIdStr) > 0n ? BigInt(askedCgIdStr) : null; + + const discovered = await this.discovery + .findNodesServingCG(args.contextGraphId) + .catch((): string[] => []); + const allPeers = Array.from(new Set([...discovered, ...(args.peers ?? [])])); + const myPeerId = this.peerId ?? 'local'; + const includeSelf = args.includeSelf ?? allPeers.includes(myPeerId); + // Cap the fan-out so a caller-supplied peer list can't make this node a + // reflector dialing arbitrary peers; bound concurrency below. + const remoteAll = allPeers.filter((p) => p !== myPeerId); + const remotePeers = remoteAll.slice(0, MAX_FANOUT_PEERS); + const truncatedPeers = remoteAll.length - remotePeers.length; + + type NodeResult = { peerId: string; result?: DragAnswerResult; error?: string }; + const thunks: Array<() => Promise> = []; + if (includeSelf) { + thunks.push(() => + this.dragAnswerLocal(args) + .then((result) => ({ peerId: myPeerId, result })) + .catch((e) => ({ peerId: myPeerId, error: e instanceof Error ? e.message : String(e) })), + ); + } + for (const peer of remotePeers) { + thunks.push(() => + this.dragAnswerRemote(peer, args) + .then((result) => ({ peerId: peer, result })) + .catch((e) => ({ peerId: peer, error: e instanceof Error ? e.message : String(e) })), + ); + } + const nodeResults = await runWithConcurrency(thunks, FANOUT_CONCURRENCY); + + // Re-verify against OUR chain (trustless aggregation). + const chain: CitationChainReads = { + getLatestMerkleRoot: (kaId) => this.chain.getLatestMerkleRoot!(kaId), + getMerkleLeafCount: (kaId) => this.chain.getMerkleLeafCount!(kaId), + getLatestMerkleRootAuthor: (kaId) => this.chain.getLatestMerkleRootAuthor!(kaId), + }; + + const citations: VerifiableCitation[] = []; + const facts: DragFact[] = []; + const kaIndex = new Map(); + const factIndex = new Map(); // factKey -> index into `citations` + const perNode: DragPerNode[] = []; + const maxCitations = Math.min(Math.max(args.maxCitations ?? 12, 1), 50); + // Verdict cache keyed on the PROOF (not just the fact), so distinct proofs for + // the same fact are each verified — a bad proof can't poison an honest one. + const verdictCache = new Map(); + const kaScopeCache = new Map(); // kaId -> belongs to asked CG? + let droppedOffScope = 0; + + for (const nr of nodeResults) { + if (nr.error || !nr.result) { + perNode.push({ peerId: nr.peerId, factsCited: 0, verified: 0, error: nr.error ?? 'no result' }); + continue; + } + let nodeVerified = 0; + let processed = 0; + for (const c of nr.result.citations) { + // Cap per-peer work so one peer's large response can't exhaust the + // asker's chain-RPC budget before the dedup gate. + if (processed >= MAX_CITATIONS_PER_PEER) break; + processed++; + try { + // CG-scope binding — only credit KAs that belong to the asked CG. + if (askedCgId !== null && typeof this.chain.getKAContextGraphId === 'function') { + let inScope = kaScopeCache.get(c.kaId); + if (inScope === undefined) { + inScope = await this.chain + .getKAContextGraphId(BigInt(c.kaId)) + .then((id) => id === askedCgId) + .catch(() => false); + kaScopeCache.set(c.kaId, inScope); + } + if (!inScope) { + droppedOffScope++; + continue; + } + } + + const proofKey = `${c.kaId}|${c.triple.subject}|${c.triple.predicate}|${c.triple.object}|${c.proof.leaf}|${c.proof.chunkId}`; + let checks = verdictCache.get(proofKey); + if (!checks) { + checks = await verifyVerifiableCitation(c, { chain }).catch( + (): CitationChecks => ({ merkle: false, onChain: false, authorSig: false, verified: false }), + ); + verdictCache.set(proofKey, checks); + } + if (checks.verified) nodeVerified++; + + // Never trust the remote's contextGraphId/ual for scope — stamp the + // asker-derived CG + the asker's own verdict. + const recited: VerifiableCitation = { + ...c, + contextGraphId: askedCgId !== null ? askedCgId.toString() : c.contextGraphId, + servingNode: nr.peerId, + checks, + }; + const factKey = `${c.kaId}|${c.triple.subject}|${c.triple.predicate}|${c.triple.object}`; + const existingIdx = factIndex.get(factKey); + if (existingIdx === undefined) { + if (citations.length < maxCitations) { + factIndex.set(factKey, citations.length); + citations.push(recited); + let idx = kaIndex.get(c.kaId); + if (idx === undefined) { + idx = kaIndex.size + 1; + kaIndex.set(c.kaId, idx); + } + facts.push({ subject: c.triple.subject, predicate: c.triple.predicate, object: c.triple.object, source: idx }); + } + } else if (!citations[existingIdx].checks.verified && checks.verified) { + // Upgrade a previously-unverified fact to a verified corroboration. + citations[existingIdx] = recited; + } + } catch { + // Skip a single malformed citation — never let it abort the answer. + continue; + } + } + perNode.push({ peerId: nr.peerId, factsCited: nr.result.citations.length, verified: nodeVerified }); + } + + const verified = citations.filter((c) => c.checks.verified).length; + const nodesAnswered = perNode.filter((p) => !p.error).length; + const answer = renderNetworkAnswer(args.question, facts, citations, kaIndex, perNode, { + truncatedPeers, + droppedOffScope, + // Scope is enforced only when the asker can resolve the CG's on-chain id + // (synced network-wide via the ontology system CG). If it cannot, the + // facts are still cryptographically verified but their CG provenance is + // unconfirmed — surface that rather than failing open silently. + scopeEnforced: askedCgId !== null, + }); + + return { + question: args.question, + contextGraphId: args.contextGraphId, + scope: 'network', + answer, + llm: false, + citations, + facts, + perNode, + stats: { keywords, servingNodes: allPeers.length, nodesAnswered, factsCited: citations.length, verified }, + }; + } +} + +const MAX_FANOUT_PEERS = 24; +const MAX_CITATIONS_PER_PEER = 64; +const FANOUT_CONCURRENCY = 8; + +/** Run thunks with a bounded number in flight; preserves input order. Thunks must not throw. */ +async function runWithConcurrency(thunks: Array<() => Promise>, limit: number): Promise { + const results: T[] = new Array(thunks.length); + let next = 0; + const worker = async (): Promise => { + while (next < thunks.length) { + const i = next++; + results[i] = await thunks[i](); + } + }; + await Promise.all(Array.from({ length: Math.min(Math.max(limit, 1), thunks.length) }, () => worker())); + return results; +} + +function isValidCitation(c: unknown): c is VerifiableCitation { + if (!c || typeof c !== 'object') return false; + const x = c as Record; + const t = x.triple as Record | undefined; + const p = x.proof as Record | undefined; + const oc = x.onChain as Record | undefined; + return ( + typeof x.kaId === 'string' && + !!t && typeof t.subject === 'string' && typeof t.predicate === 'string' && typeof t.object === 'string' && + !!p && typeof p.content === 'string' && typeof p.leaf === 'string' && Array.isArray(p.siblings) && + typeof p.chunkId === 'number' && typeof p.leafCount === 'number' && + !!oc && typeof oc.merkleRoot === 'string' && typeof oc.author === 'string' && + !!x.checks && typeof x.checks === 'object' + ); +} + +/** A peer's dRAG reply is usable only if it carries a well-formed citation array. */ +function isValidDragAnswerResult(d: unknown): d is DragAnswerResult { + if (!d || typeof d !== 'object') return false; + const x = d as Record; + return Array.isArray(x.citations) && x.citations.every(isValidCitation); +} + +/** Render a human-readable, audit-flagged answer (no LLM). */ +function renderAnswer( + question: string, + facts: DragFact[], + citations: VerifiableCitation[], + kaIndex: Map, +): string { + if (facts.length === 0) return `No verifiable facts found for "${question}".`; + + // Group facts by subject, preserving first-seen order. + const bySubject = new Map(); + for (const f of facts) { + const arr = bySubject.get(f.subject) ?? []; + arr.push(f); + bySubject.set(f.subject, arr); + } + + const lines: string[] = []; + lines.push( + `## Answer\n\nGrounded in ${facts.length} verifiable fact${facts.length === 1 ? '' : 's'} ` + + `from ${kaIndex.size} source${kaIndex.size === 1 ? '' : 's'} on this node.\n`, + ); + for (const [subject, group] of bySubject) { + lines.push(`**${subject}**`); + for (const f of group) { + lines.push(`- ${shortPredicate(f.predicate)} — ${displayObject(f.object)} [${f.source}]`); + } + lines.push(''); + } + + // One Sources entry per cited KA (citations of the same KA share verdict). + lines.push('## Sources'); + const seenKa = new Set(); + for (const c of citations) { + if (seenKa.has(c.kaId)) continue; + seenKa.add(c.kaId); + const idx = kaIndex.get(c.kaId); + const ch = c.checks; + lines.push( + `[${idx}] KA ${shortKa(c.kaId)} · cg ${c.contextGraphId} · @${c.servingNode.slice(0, 12)}` + + ` ${flag(ch.authorSig)} author-sig ${flag(ch.merkle)} merkle ${flag(ch.onChain)} on-chain`, + ); + lines.push(` UAL: ${c.ual}`); + lines.push(` root: ${shortHex(c.onChain.merkleRoot)} author: ${c.onChain.author}`); + } + return lines.join('\n'); +} + +/** Render a cross-node answer with a per-node trust breakdown (no LLM). */ +function renderNetworkAnswer( + question: string, + facts: DragFact[], + citations: VerifiableCitation[], + kaIndex: Map, + perNode: DragPerNode[], + notes: { truncatedPeers: number; droppedOffScope: number; scopeEnforced: boolean } = { + truncatedPeers: 0, + droppedOffScope: 0, + scopeEnforced: true, + }, +): string { + const answered = perNode.filter((p) => !p.error).length; + if (facts.length === 0) { + return ( + `No verifiable facts found across the network for "${question}" ` + + `(${answered}/${perNode.length} serving node${perNode.length === 1 ? '' : 's'} answered).` + ); + } + const base = renderAnswer(question, facts, citations, kaIndex); + const lines: string[] = ['', '## Network']; + lines.push( + `Assembled across ${answered} of ${perNode.length} serving node${perNode.length === 1 ? '' : 's'}; ` + + `every citation re-verified independently against the chain by this node.`, + ); + for (const p of perNode) { + lines.push( + p.error + ? `- @${p.peerId.slice(0, 12)} — error: ${p.error}` + : `- @${p.peerId.slice(0, 12)} — ${p.factsCited} fact${p.factsCited === 1 ? '' : 's'} offered, ${p.verified} verified`, + ); + } + if (notes.droppedOffScope > 0) { + lines.push(`(${notes.droppedOffScope} citation${notes.droppedOffScope === 1 ? '' : 's'} dropped: KA not in the requested context graph)`); + } + if (notes.truncatedPeers > 0) { + lines.push(`(fan-out capped at ${MAX_FANOUT_PEERS} peers; ${notes.truncatedPeers} additional serving node${notes.truncatedPeers === 1 ? '' : 's'} not queried)`); + } + if (!notes.scopeEnforced) { + lines.push('(⚠ context-graph scope NOT enforced: could not resolve the CG on-chain id — facts are cryptographically verified but their CG provenance is unconfirmed)'); + } + return `${base}\n${lines.join('\n')}`; +} diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index a569701bc..e05856218 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -12,7 +12,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { DKGNode, ProtocolRouter, GossipSubManager, TypedEventBus, DKGEvent, LibP2PNetwork, PeerResolver, StubNetworkStateRegistry, - PROTOCOL_ACCESS, PROTOCOL_PUBLISH, PROTOCOL_SYNC, PROTOCOL_QUERY_REMOTE, PROTOCOL_STORAGE_ACK, PROTOCOL_STORAGE_ACK_V2, PROTOCOL_STORAGE_UPDATE_ACK, PROTOCOL_GET_CIPHERTEXT_CHUNK, PROTOCOL_VERIFY_PROPOSAL, PROTOCOL_JOIN_REQUEST, + PROTOCOL_ACCESS, PROTOCOL_PUBLISH, PROTOCOL_SYNC, PROTOCOL_QUERY_REMOTE, PROTOCOL_DRAG_ANSWER, PROTOCOL_STORAGE_ACK, PROTOCOL_STORAGE_ACK_V2, PROTOCOL_STORAGE_UPDATE_ACK, PROTOCOL_GET_CIPHERTEXT_CHUNK, PROTOCOL_VERIFY_PROPOSAL, PROTOCOL_JOIN_REQUEST, PROTOCOL_SWM_SENDER_KEY, PROTOCOL_SWM_UPDATE, PROTOCOL_SWM_SHARE_ACK, PROTOCOL_SWM_HOST_CATCHUP, PROTOCOL_MESSAGE, contextGraphPublishTopic, contextGraphWorkspaceTopic, contextGraphAppTopic, contextGraphUpdateTopic, contextGraphFinalizationTopic, contextGraphDataGraphUri, contextGraphMetaGraphUri, contextGraphWorkspaceGraphUri, contextGraphWorkspaceMetaGraphUri, @@ -34,7 +34,7 @@ import { decodeEncryptedWorkspacePayload, ENCRYPTED_WORKSPACE_ENVELOPE_TYPE, decodeSwmSenderKeyMessage, SWM_SENDER_KEY_MESSAGE_TYPE, getGenesisQuads, computeNetworkId, SYSTEM_CONTEXT_GRAPHS, DKG_ONTOLOGY, - Logger, createOperationContext, sparqlString, escapeSparqlLiteral, isSafeIri, assertSafeIri, + Logger, createOperationContext, sparqlString, escapeSparqlLiteral, isSafeIri, assertSafeIri, RateLimiter, TrustLevel, TRUST_LEVEL_PREDICATE, buildTrustLevelQuads, @@ -723,6 +723,55 @@ export class LifecycleSyncMethods extends DKGAgentBase { }; return queryRemoteHandler.handler(data, peerIdObj); }); + // OT-RFC-55 dRAG (P3): answer a peer's NL question over a PUBLIC context + // graph with grounded, verifiable citations. ACL = the SAME on-chain + // public gate as query-remote (isContextGraphPublicOnChain, fail-closed), + // so a private/curated/unregistered CG is never answered remotely. + // + // libp2p peers carry no daemon token, so this verb is unauthenticated — + // it is therefore (a) per-peer rate limited and (b) bounded to a SMALL + // remote-path cost ceiling (the answer body runs a store scan + chain reads + // per KA), to deny the amplification-DoS the local path would otherwise expose. + const dragAnswerRateLimiter = new RateLimiter({ maxPerWindow: 30, windowMs: 60_000 }); + const DRAG_REMOTE_MAX_KAS = 15; + const DRAG_REMOTE_MAX_CITATIONS = 15; + this.messenger.register(PROTOCOL_DRAG_ANSWER, async (data, peerId) => { + const encode = (obj: unknown) => new TextEncoder().encode(JSON.stringify(obj)); + if (peerId && !dragAnswerRateLimiter.allow(peerId)) { + return encode({ error: 'dRAG: rate limited' }); + } + let req: { question?: string; contextGraphId?: string; maxCitations?: number; maxKas?: number }; + try { + req = JSON.parse(new TextDecoder().decode(data)); + } catch { + return encode({ error: 'invalid dRAG request payload' }); + } + const question = req.question; + const contextGraphId = req.contextGraphId; + if (typeof question !== 'string' || !question || typeof contextGraphId !== 'string' || !contextGraphId) { + return encode({ error: 'dRAG request requires question + contextGraphId' }); + } + try { + const isPublic = await this.isContextGraphPublicOnChain( + contextGraphId, + createOperationContext('query'), + ); + if (!isPublic) { + return encode({ error: `context graph "${contextGraphId}" is not public — dRAG fan-out is public-only in V1` }); + } + const result = await this.dragAnswerLocal({ + question, + contextGraphId, + // Clamp the remote path well below the local ceilings (50/100): a peer + // pays for an answer, not for an unbounded scan. + maxCitations: Math.min(req.maxCitations ?? DRAG_REMOTE_MAX_CITATIONS, DRAG_REMOTE_MAX_CITATIONS), + maxKas: Math.min(req.maxKas ?? DRAG_REMOTE_MAX_KAS, DRAG_REMOTE_MAX_KAS), + }); + return encode(result); + } catch (e) { + return encode({ error: e instanceof Error ? e.message : String(e) }); + } + }); // PROTOCOL_SWM_SENDER_KEY migrated onto the substrate in rc.9 PR-8. // messenger.register handles envelope unwrap + receiver dedup // before the in-process handleSwmSenderKeyPackage call. diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 18206f9c4..72947a212 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -383,6 +383,7 @@ import { CclPolicyMethods } from './dkg-agent-ccl.js'; import { EndorseVerifyMethods } from './dkg-agent-endorse.js'; import { ContextGraphRegistryMethods } from './dkg-agent-cg-registry.js'; import { JoinRequestMethods } from './dkg-agent-join.js'; +import { DragMethods } from './dkg-agent-drag.js'; import { SwmSubstrateMethods } from './dkg-agent-swm-substrate.js'; import { QueryMethods } from './dkg-agent-query.js'; import { AgentRegistryMethods } from './dkg-agent-registry.js'; @@ -2523,5 +2524,5 @@ export class DKGAgent extends DKGAgentBase { } -export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods {} -applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods]); +export interface DKGAgent extends ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, DragMethods {} +applyMixins(DKGAgent, [ImportedArtifactMethods, ContextGraphMethods, SwmHostModeMethods, PublishMethods, LifecycleSyncMethods, WorkspaceCryptoMethods, AgentRegistryMethods, QueryMethods, SwmSubstrateMethods, JoinRequestMethods, ContextGraphRegistryMethods, EndorseVerifyMethods, CclPolicyMethods, ContextGraphResolveMethods, OwnershipMethods, DragMethods]); diff --git a/packages/agent/src/drag/citation.ts b/packages/agent/src/drag/citation.ts new file mode 100644 index 000000000..b2741a024 --- /dev/null +++ b/packages/agent/src/drag/citation.ts @@ -0,0 +1,391 @@ +/** + * dRAG verifiable-citation PRODUCER + VERIFIER (OT-RFC-55 §5.3 / OT-RFC-54 Phase 1). + * + * The pure wire type and the Merkle/content-binding check live in + * `@origintrail-official/dkg-core` ({@link VerifiableCitation}, + * {@link verifyCitationProof}). THIS module is the half that needs a triple + * store, a chain adapter, and `ethers`: + * + * - {@link buildVerifiableCitation} — given a cited triple in a locally-held + * KA, extract the KA's canonical V10 leaf set, build a Merkle inclusion + * proof that re-anchors to the on-chain `getLatestMerkleRoot(kaId)`, and + * attach the EIP-712 author seal. The proof uses the SAME structured V10 + * tree the Random Sampling prover + on-chain `submitProof` use, so a + * citation that builds here verifies on-chain by construction. + * + * - {@link verifyVerifiableCitation} — re-check a citation: Merkle (pure, + * via core) + on-chain re-anchor (optional chain adapter) + EIP-712 author + * recovery (ethers). + * + * AUTHOR semantics: `getLatestMerkleRootAuthor(kaId)` IS the EIP-712 author the + * chain recovered at publish time, so it is authoritative. The off-chain seal + * recovery is a TRUSTLESS enhancement: when the `_meta` seal resolves we recover + * the signer locally and confirm it matches the on-chain author (so a verifier + * need not trust the chain read). When the seal is absent we fall back to the + * on-chain author and mark `authorSig: null` (still chain-verified, just not + * independently re-derived). + */ + +import { ethers } from 'ethers'; +import { + buildV10ProofMaterial, + structuredKARootV10, + tripleContentV10, + keccak256, + buildAuthorAttestationTypedData, + contextGraphMetaUri, + parseAssertionSealQuads, + bytesToHex0x, + type AssertionSeal, + type VerifiableCitation, + type CitationTriple, + type CitationChecks, + type CitationSeal, +} from '@origintrail-official/dkg-core'; +import { verifyCitationProof } from '@origintrail-official/dkg-core'; +import { extractV10KCFromStore } from '@origintrail-official/dkg-random-sampling'; +import type { TripleStore } from '@origintrail-official/dkg-storage'; + +/** Chain reads a citation producer/verifier needs (subset of the EVM adapter). */ +export interface CitationChainReads { + getLatestMerkleRoot(kaId: bigint): Promise; + getMerkleLeafCount(kaId: bigint): Promise; + getLatestMerkleRootAuthor(kaId: bigint): Promise; +} + +export interface BuildCitationDeps { + store: TripleStore; + chain: CitationChainReads; + /** This node's libp2p peerId (or `"local"`) — stamped as the serving node. */ + servingNode: string; + /** Chain id stamped onto the citation (for display / EIP-712 domain when no seal). */ + chainId: bigint; +} + +/** Thrown when the cited triple is not a public leaf of the named KA. */ +export class CitedTripleNotInKAError extends Error { + readonly name = 'CitedTripleNotInKAError'; + constructor(readonly kaId: bigint, readonly triple: CitationTriple) { + super( + `cited triple <${triple.subject}> <${triple.predicate}> ${triple.object} ` + + `is not a public leaf of KA ${kaId}`, + ); + } +} + +const ZERO_ADDR = '0x0000000000000000000000000000000000000000'; + +function isNonZeroAddress(addr: string): boolean { + return typeof addr === 'string' && /^0x[0-9a-fA-F]{40}$/.test(addr) && addr.toLowerCase() !== ZERO_ADDR; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +function eqHex(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +function stripAngle(iri: string): string { + const m = iri.match(/^<(.+)>$/); + return m ? m[1] : iri; +} + +/** + * Index of `citedContent`'s keccak leaf in the KA's post sort+dedupe public tree + * (the on-chain `chunkId` space), or -1 if the cited triple is not a public leaf. + */ +function findLeafIndex( + contents: Uint8Array[], + privateRoots: Uint8Array[], + citedContent: Uint8Array, +): number { + const leaves = contents.map((c) => keccak256(c)); + const { publicTree } = structuredKARootV10(leaves, privateRoots); + const citedLeaf = keccak256(citedContent); + for (let i = 0; i < publicTree.leafCount; i++) { + if (bytesEqual(publicTree.leafAt(i), citedLeaf)) return i; + } + return -1; +} + +// ── author seal ─────────────────────────────────────────────────────────────── + +function sealToWire(s: AssertionSeal): CitationSeal { + return { + merkleRoot: bytesToHex0x(s.merkleRoot), + authorAddress: s.authorAddress, + r: bytesToHex0x(s.authorAttestationR), + vs: bytesToHex0x(s.authorAttestationVS), + schemeVersion: s.authorSchemeVersion, + chainId: s.chainId.toString(), + kav10Address: s.kav10Address, + reservedKaId: (s.reservedKaId ?? 0n).toString(), + }; +} + +/** Recover the EIP-712 author EOA from a wire seal. Throws on malformed sig/typed data. */ +export function recoverCitationAuthor(seal: CitationSeal): string { + const typed = buildAuthorAttestationTypedData({ + chainId: BigInt(seal.chainId), + kav10Address: seal.kav10Address, + merkleRoot: ethers.getBytes(seal.merkleRoot), + authorAddress: seal.authorAddress, + reservedKaId: BigInt(seal.reservedKaId), + schemeVersion: seal.schemeVersion, + }); + const sig = ethers.Signature.from({ r: seal.r, yParityAndS: seal.vs }).serialized; + return ethers.verifyTypedData(typed.domain, typed.types, typed.message, sig); +} + +/** + * Best-effort: load the `_meta` author seal whose `assertionMerkleRoot` equals the + * on-chain root. Matching by root (not by assertion URI) makes this robust to how + * the seal subject is keyed (assertion URI vs UAL vs remapped graph). + */ +async function loadSealByMerkleRoot( + store: TripleStore, + contextGraphName: string, + contextGraphIdStr: string, + merkleRoot: Uint8Array, +): Promise { + const rootLexical = bytesToHex0x(merkleRoot).slice(2).toLowerCase(); // xsd:hexBinary lexical (no 0x) + // The seal is written by finalize to the NAME-only `_meta` graph + // (`contextGraphMetaUri(name)`); the cgId-scoped meta is checked as a + // fallback in case a future remap relocates it. + const metaGraphs = Array.from( + new Set([contextGraphMetaUri(contextGraphName), contextGraphMetaUri(contextGraphName, contextGraphIdStr)]), + ); + for (const metaGraph of metaGraphs) { + const sel = await store.query( + `SELECT DISTINCT ?s WHERE { + GRAPH <${metaGraph}> { + ?s ?r . + FILTER(LCASE(STR(?r)) = "${rootLexical}") + } + } LIMIT 8`, + ); + const subjects = + sel.type === 'bindings' + ? sel.bindings.map((b) => stripAngle(b['s'] ?? '')).filter((s) => s.length > 0) + : []; + for (const subj of subjects) { + const c = await store.query( + `CONSTRUCT { <${subj}> ?p ?o } WHERE { GRAPH <${metaGraph}> { <${subj}> ?p ?o } }`, + ); + const quads = c.type === 'quads' ? c.quads : []; + let seal: AssertionSeal | undefined; + try { + seal = parseAssertionSealQuads(quads, subj); + } catch { + seal = undefined; // partial/corrupt seal — skip, fall back to on-chain author + } + if (seal && eqHex(bytesToHex0x(seal.merkleRoot), bytesToHex0x(merkleRoot))) return seal; + } + } + return undefined; +} + +// ── producer ────────────────────────────────────────────────────────────────── + +/** + * The per-KA work that does NOT depend on the specific cited triple: extract the + * canonical leaf set, read the on-chain commitment + author, load + recover the + * author seal. Prepared once per KA, then reused to cite many triples of that KA + * via {@link citeTriple} — avoiding redundant store extraction + chain reads. + * + * `triples` are the KA's canonical public triples (the exact forms the Merkle + * leaves are built from); cite only triples drawn from this set so the leaf + * always matches. + */ +export interface PreparedKaCitation { + ual: string; + contextGraphIdStr: string; + kaIdStr: string; + servingNode: string; + /** Canonical N-Triple content bytes of each public triple (unsorted). */ + contents: Uint8Array[]; + privateRoots: Uint8Array[]; + /** The KA's canonical public triples (cite only from this set). */ + triples: CitationTriple[]; + merkleRoot: Uint8Array; + merkleLeafCount: number; + author: string; + chainId: bigint; + seal?: CitationSeal; + /** Per-KA author verdict (same for every triple of the KA). */ + authorSig: boolean | null; +} + +/** + * Resolve everything a citation needs for ALL triples of one KA: extract the + * canonical leaf set, read the on-chain commitment + author, and recover the + * author seal once. + */ +export async function prepareKaCitation( + deps: BuildCitationDeps, + args: { contextGraphId: bigint; kaId: bigint }, +): Promise { + const { store, chain, servingNode } = deps; + const { contextGraphId, kaId } = args; + + const kc = await extractV10KCFromStore(store, contextGraphId, kaId); + const contents = kc.triples.map((t) => tripleContentV10(t.subject, t.predicate, t.object)); + + const [merkleRoot, merkleLeafCount, author] = await Promise.all([ + chain.getLatestMerkleRoot(kaId), + chain.getMerkleLeafCount(kaId), + chain.getLatestMerkleRootAuthor(kaId), + ]); + + let seal: CitationSeal | undefined; + let authorSig: boolean | null; + const loaded = await loadSealByMerkleRoot( + store, + kc.contextGraphName, + contextGraphId.toString(), + merkleRoot, + ).catch(() => undefined); + if (loaded) { + seal = sealToWire(loaded); + let recovered = ''; + try { + recovered = recoverCitationAuthor(seal); + } catch { + recovered = ''; + } + authorSig = + isNonZeroAddress(recovered) && eqHex(recovered, author) && eqHex(seal.merkleRoot, bytesToHex0x(merkleRoot)); + } else { + authorSig = isNonZeroAddress(author) ? null : false; + } + + return { + ual: kc.ual, + contextGraphIdStr: contextGraphId.toString(), + kaIdStr: kaId.toString(), + servingNode, + contents, + privateRoots: kc.privateRoots, + triples: kc.triples.map((t) => ({ subject: t.subject, predicate: t.predicate, object: t.object })), + merkleRoot, + merkleLeafCount, + author, + chainId: seal ? BigInt(seal.chainId) : deps.chainId, + seal, + authorSig, + }; +} + +/** + * Build a {@link VerifiableCitation} for one triple of an already-{@link prepareKaCitation prepared} + * KA. Pure (no async): builds the V10 Merkle proof and asserts it re-anchors to + * the prepared on-chain root. Throws {@link CitedTripleNotInKAError} if the + * triple is not a public leaf of the KA. + */ +export function citeTriple(prepared: PreparedKaCitation, triple: CitationTriple): VerifiableCitation { + const citedContent = tripleContentV10(triple.subject, triple.predicate, triple.object); + const chunkId = findLeafIndex(prepared.contents, prepared.privateRoots, citedContent); + if (chunkId < 0) throw new CitedTripleNotInKAError(BigInt(prepared.kaIdStr), triple); + + const material = buildV10ProofMaterial(prepared.contents, prepared.privateRoots, chunkId, { + merkleRoot: prepared.merkleRoot, + merkleLeafCount: prepared.merkleLeafCount, + }); + const onChainOk = bytesEqual(material.merkleRoot, prepared.merkleRoot); + + return { + ual: prepared.ual, + kaId: prepared.kaIdStr, + contextGraphId: prepared.contextGraphIdStr, + servingNode: prepared.servingNode, + triple, + proof: { + content: bytesToHex0x(material.content), + leaf: bytesToHex0x(material.leaf), + siblings: material.proof.map(bytesToHex0x), + chunkId, + leafCount: material.leafCount, + }, + onChain: { + merkleRoot: bytesToHex0x(prepared.merkleRoot), + author: prepared.author, + chainId: prepared.chainId.toString(), + }, + seal: prepared.seal, + checks: { + merkle: true, + onChain: onChainOk, + authorSig: prepared.authorSig, + verified: onChainOk && prepared.authorSig !== false, + }, + }; +} + +/** + * Build a {@link VerifiableCitation} for one cited triple of a locally-held KA + * (convenience: {@link prepareKaCitation} + {@link citeTriple} for a single fact). + */ +export async function buildVerifiableCitation( + deps: BuildCitationDeps, + args: { contextGraphId: bigint; kaId: bigint; triple: CitationTriple }, +): Promise { + const prepared = await prepareKaCitation(deps, args); + return citeTriple(prepared, args.triple); +} + +// ── verifier ──────────────────────────────────────────────────────────────── + +/** + * Re-verify a {@link VerifiableCitation}. Merkle + content-binding is pure (core). + * Pass `opts.chain` to re-anchor the root and author against the LIVE chain + * (fully trustless); omit it to verify against the carried on-chain values + * (offline / self-consistent). + */ +export async function verifyVerifiableCitation( + citation: VerifiableCitation, + opts?: { chain?: CitationChainReads }, +): Promise { + const merkle = verifyCitationProof(citation); + + let onChain: boolean | null = citation.checks.onChain; + let expectedAuthor = citation.onChain.author; + if (opts?.chain) { + const [liveRoot, liveLeafCount, liveAuthor] = await Promise.all([ + opts.chain.getLatestMerkleRoot(BigInt(citation.kaId)), + opts.chain.getMerkleLeafCount(BigInt(citation.kaId)), + opts.chain.getLatestMerkleRootAuthor(BigInt(citation.kaId)), + ]); + // Re-anchor BOTH the root and the leaf count to the live chain — the pure + // verifier checks proof.leafCount against itself (tautological), so the + // chain read is what actually validates the carried leaf count. + onChain = eqHex(bytesToHex0x(liveRoot), citation.onChain.merkleRoot) && liveLeafCount === citation.proof.leafCount; + expectedAuthor = liveAuthor; + } + + let authorSig: boolean | null; + if (citation.seal) { + let recovered = ''; + try { + recovered = recoverCitationAuthor(citation.seal); + } catch { + recovered = ''; + } + authorSig = + isNonZeroAddress(recovered) && + eqHex(recovered, expectedAuthor) && + eqHex(citation.seal.merkleRoot, citation.onChain.merkleRoot); + } else { + authorSig = isNonZeroAddress(expectedAuthor) ? null : false; + } + + return { + merkle, + onChain, + authorSig, + verified: merkle && onChain !== false && authorSig !== false, + }; +} diff --git a/packages/agent/test/drag-citation.test.ts b/packages/agent/test/drag-citation.test.ts new file mode 100644 index 000000000..ab74c0086 --- /dev/null +++ b/packages/agent/test/drag-citation.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from 'vitest'; +import { ethers } from 'ethers'; +import { + buildV10ProofMaterial, + structuredKARootV10, + tripleContentV10, + hashTripleV10, + keccak256, + bytesToHex0x, + buildAuthorAttestationTypedData, + type CitationTriple, + type CitationSeal, + type VerifiableCitation, +} from '@origintrail-official/dkg-core'; +import { + recoverCitationAuthor, + verifyVerifiableCitation, + type CitationChainReads, +} from '../src/drag/citation.js'; + +// Author-seal recovery + end-to-end verify for dRAG verifiable citations. +// Uses a real ethers wallet to sign the EIP-712 AuthorAttestation, so the +// off-chain recovery path is exercised against a genuine signature (the same +// shape the publisher produces and the chain recovers at publish time). + +const CHAIN_ID = 31337n; +const KAV10 = '0x0000000000000000000000000000000000000042'; + +const TRIPLES: CitationTriple[] = [ + { subject: 'urn:drag:fact', predicate: 'http://schema.org/name', object: '"Northwind Components"' }, + { subject: 'urn:drag:fact', predicate: 'http://schema.org/auditStatus', object: '"flagged"' }, + { subject: 'urn:drag:fact', predicate: 'http://schema.org/incidents', object: '"3"' }, +]; + +function bytesEq(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +async function signSeal( + wallet: ethers.Wallet, + merkleRoot: Uint8Array, +): Promise { + const reservedKaId = (BigInt(wallet.address) << 96n) | 1n; + const typed = buildAuthorAttestationTypedData({ + chainId: CHAIN_ID, + kav10Address: KAV10, + merkleRoot, + authorAddress: wallet.address, + reservedKaId, + schemeVersion: 1, + }); + const sig = await wallet.signTypedData(typed.domain, typed.types, typed.message); + const s = ethers.Signature.from(sig); + return { + merkleRoot: bytesToHex0x(merkleRoot), + authorAddress: wallet.address, + r: s.r, + vs: s.yParityAndS, + schemeVersion: 1, + chainId: CHAIN_ID.toString(), + kav10Address: KAV10, + reservedKaId: reservedKaId.toString(), + }; +} + +async function buildSignedCitation( + wallet: ethers.Wallet, + triples: CitationTriple[], + citedIndex: number, +): Promise<{ citation: VerifiableCitation; root: Uint8Array }> { + const contents = triples.map((t) => tripleContentV10(t.subject, t.predicate, t.object)); + const leaves = contents.map((c) => keccak256(c)); + const { root, leafCount, publicTree } = structuredKARootV10(leaves, []); + + const cited = triples[citedIndex]; + const citedLeaf = hashTripleV10(cited.subject, cited.predicate, cited.object); + let chunkId = -1; + for (let i = 0; i < publicTree.leafCount; i++) { + if (bytesEq(publicTree.leafAt(i), citedLeaf)) { chunkId = i; break; } + } + const material = buildV10ProofMaterial(contents, [], chunkId, { merkleRoot: root, merkleLeafCount: leafCount }); + const seal = await signSeal(wallet, root); + + const citation: VerifiableCitation = { + ual: 'did:dkg:context-graph:drag-test/0x00/1', + kaId: '1', + contextGraphId: '3', + servingNode: '12D3KooWTest', + triple: cited, + proof: { + content: bytesToHex0x(material.content), + leaf: bytesToHex0x(material.leaf), + siblings: material.proof.map(bytesToHex0x), + chunkId, + leafCount: material.leafCount, + }, + onChain: { merkleRoot: bytesToHex0x(root), author: wallet.address, chainId: CHAIN_ID.toString() }, + seal, + checks: { merkle: true, onChain: true, authorSig: true, verified: true }, + }; + return { citation, root }; +} + +function mockChain(root: Uint8Array, author: string, leafCount = 0): CitationChainReads { + return { + getLatestMerkleRoot: async () => root, + getMerkleLeafCount: async () => leafCount, + getLatestMerkleRootAuthor: async () => author, + }; +} + +describe('dRAG citation — EIP-712 author seal', () => { + it('recovers the signing author from a compact-sig seal', async () => { + const wallet = ethers.Wallet.createRandom(); + const root = new Uint8Array(32).fill(0x11); + const seal = await signSeal(wallet as unknown as ethers.Wallet, root); + expect(recoverCitationAuthor(seal).toLowerCase()).toBe(wallet.address.toLowerCase()); + }); + + it('a tampered seal merkleRoot recovers a different (wrong) author', async () => { + const wallet = ethers.Wallet.createRandom(); + const root = new Uint8Array(32).fill(0x11); + const seal = await signSeal(wallet as unknown as ethers.Wallet, root); + const tampered = { ...seal, merkleRoot: bytesToHex0x(new Uint8Array(32).fill(0x22)) }; + expect(recoverCitationAuthor(tampered).toLowerCase()).not.toBe(wallet.address.toLowerCase()); + }); +}); + +describe('dRAG citation — verifyVerifiableCitation', () => { + it('verifies a well-formed signed citation (offline, carried values)', async () => { + const wallet = ethers.Wallet.createRandom(); + const { citation } = await buildSignedCitation(wallet as unknown as ethers.Wallet, TRIPLES, 0); + const checks = await verifyVerifiableCitation(citation); + expect(checks).toMatchObject({ merkle: true, authorSig: true, verified: true }); + }); + + it('verifies against a live chain (re-anchor root + leaf count + author)', async () => { + const wallet = ethers.Wallet.createRandom(); + const { citation, root } = await buildSignedCitation(wallet as unknown as ethers.Wallet, TRIPLES, 1); + const checks = await verifyVerifiableCitation(citation, { + chain: mockChain(root, wallet.address, citation.proof.leafCount), + }); + expect(checks).toEqual({ merkle: true, onChain: true, authorSig: true, verified: true }); + }); + + it('fails on-chain when the live leaf count disagrees with the carried proof', async () => { + const wallet = ethers.Wallet.createRandom(); + const { citation, root } = await buildSignedCitation(wallet as unknown as ethers.Wallet, TRIPLES, 0); + const checks = await verifyVerifiableCitation(citation, { + chain: mockChain(root, wallet.address, citation.proof.leafCount + 1), + }); + expect(checks.onChain).toBe(false); + expect(checks.verified).toBe(false); + }); + + it('fails when the live chain root disagrees (stale / forged anchor)', async () => { + const wallet = ethers.Wallet.createRandom(); + const { citation } = await buildSignedCitation(wallet as unknown as ethers.Wallet, TRIPLES, 0); + const wrongRoot = new Uint8Array(32).fill(0xab); + const checks = await verifyVerifiableCitation(citation, { chain: mockChain(wrongRoot, wallet.address) }); + expect(checks.onChain).toBe(false); + expect(checks.verified).toBe(false); + }); + + it('fails authorSig when the on-chain author differs from the seal signer', async () => { + const wallet = ethers.Wallet.createRandom(); + const { citation, root } = await buildSignedCitation(wallet as unknown as ethers.Wallet, TRIPLES, 0); + const checks = await verifyVerifiableCitation(citation, { + chain: mockChain(root, '0x000000000000000000000000000000000000dEaD'), + }); + expect(checks.authorSig).toBe(false); + expect(checks.verified).toBe(false); + }); + + it('content-binding: a swapped triple object fails merkle even with a valid seal', async () => { + const wallet = ethers.Wallet.createRandom(); + const { citation } = await buildSignedCitation(wallet as unknown as ethers.Wallet, TRIPLES, 0); + const tampered: VerifiableCitation = { ...citation, triple: { ...citation.triple, object: '"Globex"' } }; + const checks = await verifyVerifiableCitation(tampered); + expect(checks.merkle).toBe(false); + expect(checks.verified).toBe(false); + }); + + it('falls back to onChain author (authorSig=null) when no seal is present', async () => { + const wallet = ethers.Wallet.createRandom(); + const { citation } = await buildSignedCitation(wallet as unknown as ethers.Wallet, TRIPLES, 0); + const sealless: VerifiableCitation = { ...citation, seal: undefined }; + const checks = await verifyVerifiableCitation(sealless); + expect(checks.authorSig).toBeNull(); + expect(checks.merkle).toBe(true); + expect(checks.verified).toBe(true); // chain-verified author still trusted + }); + + it('a sealless citation with a zero author is not verified', async () => { + const wallet = ethers.Wallet.createRandom(); + const { citation } = await buildSignedCitation(wallet as unknown as ethers.Wallet, TRIPLES, 0); + const bad: VerifiableCitation = { + ...citation, + seal: undefined, + onChain: { ...citation.onChain, author: '0x0000000000000000000000000000000000000000' }, + }; + const checks = await verifyVerifiableCitation(bad); + expect(checks.authorSig).toBe(false); + expect(checks.verified).toBe(false); + }); +}); diff --git a/packages/cli/src/daemon/handle-request.ts b/packages/cli/src/daemon/handle-request.ts index 5021b2fba..8ac189455 100644 --- a/packages/cli/src/daemon/handle-request.ts +++ b/packages/cli/src/daemon/handle-request.ts @@ -326,6 +326,7 @@ import { handleKnowledgeAssetsRoutes } from './routes/knowledge-assets.js'; import { handleKcChainMetadataRoutes } from './routes/kc-chain-metadata.js'; import { handleFileServingRoutes } from './routes/file-serving.js'; import { handleQueryRoutes } from './routes/query.js'; +import { handleDragRoutes } from './routes/drag.js'; import { handleLocalAgentsRoutes } from './routes/local-agents.js'; import { handleEpcisRoutes } from './routes/epcis.js'; import { handlePcaRoutes } from './routes/pca.js'; @@ -446,6 +447,9 @@ export async function handleRequest( await handleQueryRoutes(ctx); if (res.writableEnded) return; + await handleDragRoutes(ctx); + if (res.writableEnded) return; + await handleLocalAgentsRoutes(ctx); if (res.writableEnded) return; diff --git a/packages/cli/src/daemon/payment.ts b/packages/cli/src/daemon/payment.ts new file mode 100644 index 000000000..fb4abb093 --- /dev/null +++ b/packages/cli/src/daemon/payment.ts @@ -0,0 +1,208 @@ +// daemon/payment.ts +// +// x402-style payment seam for paid dRAG answers (OT-RFC-55 §5.4). +// +// V1 ships the WIRE FORMAT (HTTP 402 challenge + `X-PAYMENT` header) and a +// pluggable {@link PaymentVerifier} with a {@link MockPaymentVerifier} +// (accept-any, for dev/CI). The real Coinbase/USDC facilitator drops in behind +// the SAME interface — the route does not change. Public CGs are FREE in V1; +// real per-CG / market pricing and the live facilitator are deferred (see the +// PR notes). This module is self-contained and pure (no settlement side +// effects beyond what a verifier implements), so the payment gate can be +// unit-tested without a chain or a facilitator. +// +// Wire shapes mirror x402: the 402 body carries `{ x402Version, error, accepts: +// [PaymentRequirements] }`; the client retries with `X-PAYMENT: base64(JSON +// PaymentPayload)`; on success the response carries a settlement receipt. + +export const X402_VERSION = 1; + +/** What the server asks the client to pay (one entry in the 402 `accepts` array). */ +export interface PaymentRequirements { + scheme: 'exact'; + /** Settlement network, e.g. `base-sepolia` / `base`. */ + network: string; + /** Token symbol or contract address, e.g. `USDC`. */ + asset: string; + /** Required amount, human units in V1 (e.g. `"0.01"`). */ + amount: string; + /** Receiving address. */ + payTo: string; + /** The priced resource (request path). */ + resource: string; + /** Per-challenge nonce echoed by the payer. */ + nonce: string; + description?: string; +} + +/** The signed payment the client returns in `X-PAYMENT`. */ +export interface PaymentPayload { + x402Version: number; + scheme: string; + network: string; + asset: string; + amount: string; + payTo: string; + nonce: string; + /** Payer address. */ + from?: string; + /** Opaque signed authorization (EIP-3009 / Permit2 in real x402); ignored by the mock. */ + authorization?: string; +} + +/** The verifier's verdict; attached to a paid answer as `settlement`. */ +export interface SettlementReceipt { + ok: boolean; + scheme: string; + network: string; + asset: string; + amount: string; + payTo: string; + from?: string; + /** Settlement reference (a real tx hash, or a synthetic id for the mock). */ + txRef?: string; + /** Present when `ok === false`. */ + reason?: string; +} + +/** The settlement boundary. Swap {@link MockPaymentVerifier} for a real facilitator. */ +export interface PaymentVerifier { + verify(payload: PaymentPayload, required: PaymentRequirements): Promise; +} + +/** + * Dev/CI verifier: accepts any payment that MATCHES the challenge (network, + * asset, payTo, and amount ≥ required). Performs NO on-chain settlement — it + * returns a synthetic receipt so the 402 → pay → 200 flow is exercisable end to + * end without a facilitator or funded wallet. Mirrors the `MockChainAdapter` + * idiom used elsewhere in the codebase. + */ +export class MockPaymentVerifier implements PaymentVerifier { + async verify(payload: PaymentPayload, required: PaymentRequirements): Promise { + const base = { + scheme: required.scheme, + network: required.network, + asset: required.asset, + amount: required.amount, + payTo: required.payTo, + from: payload.from, + }; + const fail = (reason: string): SettlementReceipt => ({ ok: false, ...base, reason }); + if (payload.network !== required.network) return fail('network mismatch'); + if (payload.asset !== required.asset) return fail('asset mismatch'); + if (payload.payTo.toLowerCase() !== required.payTo.toLowerCase()) return fail('payTo mismatch'); + if (!amountGte(payload.amount, required.amount)) return fail('insufficient amount'); + return { ok: true, ...base, txRef: `mock-settle-${required.nonce}` }; + } +} + +/** Compare decimal-string amounts. Returns `a >= b`. */ +export function amountGte(a: string, b: string): boolean { + const x = Number(a); + const y = Number(b); + if (!Number.isFinite(x) || !Number.isFinite(y)) return false; + return x >= y; +} + +/** Parse a `" "` price string (e.g. `"0.01 USDC"`). Null if invalid. */ +export function parsePrice(price: string): { amount: string; asset: string } | null { + const m = price.trim().match(/^([0-9]+(?:\.[0-9]+)?)\s+([A-Za-z][A-Za-z0-9]*)$/); + if (!m) return null; + if (Number(m[1]) <= 0) return null; + return { amount: m[1], asset: m[2].toUpperCase() }; +} + +/** Build the 402 response body (x402 `accepts` envelope). */ +export function build402Body(required: PaymentRequirements): { + x402Version: number; + error: string; + accepts: PaymentRequirements[]; +} { + return { x402Version: X402_VERSION, error: 'payment required', accepts: [required] }; +} + +/** Decode an `X-PAYMENT: base64(JSON)` header into a {@link PaymentPayload}. Null if absent/invalid. */ +export function parseXPaymentHeader(header: string | string[] | undefined): PaymentPayload | null { + const raw = Array.isArray(header) ? header[0] : header; + if (!raw || typeof raw !== 'string') return null; + let json: string; + try { + json = Buffer.from(raw, 'base64').toString('utf8'); + } catch { + return null; + } + let obj: unknown; + try { + obj = JSON.parse(json); + } catch { + return null; + } + if (!obj || typeof obj !== 'object') return null; + const p = obj as Record; + if ( + typeof p.scheme !== 'string' || + typeof p.network !== 'string' || + typeof p.asset !== 'string' || + typeof p.amount !== 'string' || + typeof p.payTo !== 'string' + ) { + return null; + } + return { + x402Version: typeof p.x402Version === 'number' ? p.x402Version : X402_VERSION, + scheme: p.scheme, + network: p.network, + asset: p.asset, + amount: p.amount, + payTo: p.payTo, + nonce: typeof p.nonce === 'string' ? p.nonce : '', + from: typeof p.from === 'string' ? p.from : undefined, + authorization: typeof p.authorization === 'string' ? p.authorization : undefined, + }; +} + +/** Encode a {@link PaymentPayload} as an `X-PAYMENT` header value (clients/tests). */ +export function encodeXPaymentHeader(payload: PaymentPayload): string { + return Buffer.from(JSON.stringify(payload)).toString('base64'); +} + +/** + * Decide whether a dRAG answer request must pay, and (if a payment is present) + * verify it. Pure given its inputs + the injected verifier — no global state. + * + * Returns: + * - `{ kind: 'free' }` — no price configured; serve for free. + * - `{ kind: 'challenge', ... }` — priced, no/invalid payment → caller emits 402. + * - `{ kind: 'paid', receipt }` — payment verified → caller serves + attaches receipt. + */ +export async function resolvePayment(opts: { + price?: { amount: string; asset: string }; + network: string; + payTo: string; + resource: string; + nonce: string; + xPaymentHeader: string | string[] | undefined; + verifier: PaymentVerifier; +}): Promise< + | { kind: 'free' } + | { kind: 'challenge'; required: PaymentRequirements; reason?: string } + | { kind: 'paid'; receipt: SettlementReceipt } +> { + if (!opts.price) return { kind: 'free' }; + const required: PaymentRequirements = { + scheme: 'exact', + network: opts.network, + asset: opts.price.asset, + amount: opts.price.amount, + payTo: opts.payTo, + resource: opts.resource, + nonce: opts.nonce, + description: 'dRAG verifiable answer', + }; + const payment = parseXPaymentHeader(opts.xPaymentHeader); + if (!payment) return { kind: 'challenge', required }; + // Verify against the challenge but honour the payer's echoed nonce. + const receipt = await opts.verifier.verify(payment, { ...required, nonce: payment.nonce || required.nonce }); + if (!receipt.ok) return { kind: 'challenge', required, reason: receipt.reason }; + return { kind: 'paid', receipt }; +} diff --git a/packages/cli/src/daemon/routes/drag.ts b/packages/cli/src/daemon/routes/drag.ts new file mode 100644 index 000000000..254a1d9fb --- /dev/null +++ b/packages/cli/src/daemon/routes/drag.ts @@ -0,0 +1,112 @@ +// daemon/routes/drag.ts +// +// dRAG routes (OT-RFC-55). +// +// POST /api/answer { question, contextGraphId, scope?, peers?, maxCitations?, maxKas?, simulatePrice? } +// -> agent.dragAnswerLocal | dragAnswerNetwork +// -> { answer, citations[], facts[], stats, perNode?, settlement? } +// +// Each citation in the response is independently auditable against the chain +// (V10 Merkle inclusion + on-chain root + EIP-712 author seal). No LLM is +// required — retrieval is keyword/structural (the demoable baseline). +// +// PAYMENT (OT-RFC-55 §5.4): public CGs are FREE in V1. The x402 wire format + +// pluggable PaymentVerifier are wired here so monetization is one swap away. +// `simulatePrice` (e.g. "0.01 USDC") is a demo/test knob that exercises the +// full 402 -> X-PAYMENT -> 200+receipt flow with the MockPaymentVerifier; +// real per-CG pricing + the live facilitator are deferred (see PR notes). + +import { randomUUID } from 'node:crypto'; +import type { RequestContext } from './context.js'; +import { jsonResponse, readBody } from '../http-utils.js'; +import { + MockPaymentVerifier, + parsePrice, + resolvePayment, + build402Body, + type PaymentVerifier, + type SettlementReceipt, +} from '../payment.js'; + +// V1 default verifier. The real Coinbase/USDC facilitator drops in behind this +// same interface (config-gated) without touching the route. +const dragPaymentVerifier: PaymentVerifier = new MockPaymentVerifier(); + +export async function handleDragRoutes(ctx: RequestContext): Promise { + const { req, res, agent, path, opWallets } = ctx; + + // POST /api/answer — grounded, cited answer (local or network). + if (req.method === 'POST' && path === '/api/answer') { + let parsed: Record; + try { + parsed = JSON.parse(await readBody(req)); + } catch { + jsonResponse(res, 400, { error: 'invalid JSON body' }); + return; + } + const question = parsed.question; + const contextGraphId = (parsed.contextGraphId ?? parsed.projectId) as unknown; + if (typeof question !== 'string' || !question.trim()) { + jsonResponse(res, 400, { error: 'Missing "question"' }); + return; + } + if (typeof contextGraphId !== 'string' || !contextGraphId) { + jsonResponse(res, 400, { error: 'Missing "contextGraphId" (or "projectId")' }); + return; + } + + // ── payment gate (localized; default free) ────────────────────────────── + const priceStr = typeof parsed.simulatePrice === 'string' ? parsed.simulatePrice : undefined; + let settlement: SettlementReceipt | undefined; + if (priceStr) { + const price = parsePrice(priceStr); + if (!price) { + jsonResponse(res, 400, { error: `invalid simulatePrice "${priceStr}" — expected e.g. "0.01 USDC"` }); + return; + } + const payTo = + opWallets?.adminWallet?.address ?? + opWallets?.wallets?.[0]?.address ?? + '0x000000000000000000000000000000000000dEaD'; + const pay = await resolvePayment({ + price, + network: 'base-sepolia', + payTo, + resource: '/api/answer', + nonce: randomUUID(), + xPaymentHeader: req.headers['x-payment'], + verifier: dragPaymentVerifier, + }); + if (pay.kind === 'challenge') { + jsonResponse(res, 402, { + ...build402Body(pay.required), + ...(pay.reason ? { reason: pay.reason } : {}), + }); + return; + } + if (pay.kind === 'paid') settlement = pay.receipt; + } + + // ── answer ────────────────────────────────────────────────────────────── + const scope = parsed.scope === 'network' ? 'network' : 'local'; + const common = { + question, + contextGraphId, + maxCitations: typeof parsed.maxCitations === 'number' ? parsed.maxCitations : undefined, + maxKas: typeof parsed.maxKas === 'number' ? parsed.maxKas : undefined, + }; + const peers = Array.isArray(parsed.peers) + ? parsed.peers.filter((p): p is string => typeof p === 'string') + : undefined; + try { + const result = + scope === 'network' + ? await agent.dragAnswerNetwork({ ...common, peers }) + : await agent.dragAnswerLocal(common); + jsonResponse(res, 200, settlement ? { ...result, settlement } : result); + } catch (e) { + jsonResponse(res, 500, { error: e instanceof Error ? e.message : String(e) }); + } + return; + } +} diff --git a/packages/cli/test/drag-payment.test.ts b/packages/cli/test/drag-payment.test.ts new file mode 100644 index 000000000..76a3d8c05 --- /dev/null +++ b/packages/cli/test/drag-payment.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { + MockPaymentVerifier, + parsePrice, + amountGte, + parseXPaymentHeader, + encodeXPaymentHeader, + build402Body, + resolvePayment, + X402_VERSION, + type PaymentPayload, +} from '../src/daemon/payment.js'; + +const REQ = { + network: 'base-sepolia', + payTo: '0x1111111111111111111111111111111111111111', + resource: '/api/answer', + nonce: 'nonce-1', +}; + +function payload(over: Partial = {}): PaymentPayload { + return { + x402Version: X402_VERSION, + scheme: 'exact', + network: 'base-sepolia', + asset: 'USDC', + amount: '0.01', + payTo: REQ.payTo, + nonce: 'nonce-1', + from: '0x2222222222222222222222222222222222222222', + ...over, + }; +} + +describe('dRAG payment — parsing helpers', () => { + it('parsePrice accepts " " and normalises the asset', () => { + expect(parsePrice('0.01 USDC')).toEqual({ amount: '0.01', asset: 'USDC' }); + expect(parsePrice('5 trac')).toEqual({ amount: '5', asset: 'TRAC' }); + }); + it('parsePrice rejects malformed / non-positive prices', () => { + expect(parsePrice('USDC')).toBeNull(); + expect(parsePrice('0.01')).toBeNull(); + expect(parsePrice('-1 USDC')).toBeNull(); + expect(parsePrice('0 USDC')).toBeNull(); + expect(parsePrice('abc USDC')).toBeNull(); + }); + it('amountGte compares decimal strings', () => { + expect(amountGte('0.02', '0.01')).toBe(true); + expect(amountGte('0.01', '0.01')).toBe(true); + expect(amountGte('0.009', '0.01')).toBe(false); + expect(amountGte('x', '0.01')).toBe(false); + }); + it('X-PAYMENT header round-trips through base64(JSON)', () => { + const p = payload(); + const decoded = parseXPaymentHeader(encodeXPaymentHeader(p)); + expect(decoded).toMatchObject({ scheme: 'exact', asset: 'USDC', amount: '0.01', payTo: REQ.payTo }); + }); + it('parseXPaymentHeader returns null for absent / garbage headers', () => { + expect(parseXPaymentHeader(undefined)).toBeNull(); + expect(parseXPaymentHeader('not-base64-{')).toBeNull(); + expect(parseXPaymentHeader(Buffer.from('{"scheme":1}').toString('base64'))).toBeNull(); + }); + it('build402Body wraps the requirements in an x402 accepts envelope', () => { + const body = build402Body({ scheme: 'exact', network: 'base-sepolia', asset: 'USDC', amount: '0.01', payTo: REQ.payTo, resource: '/api/answer', nonce: 'n' }); + expect(body.x402Version).toBe(X402_VERSION); + expect(body.accepts).toHaveLength(1); + expect(body.accepts[0].asset).toBe('USDC'); + }); +}); + +describe('dRAG payment — MockPaymentVerifier', () => { + const v = new MockPaymentVerifier(); + const required = { scheme: 'exact' as const, network: 'base-sepolia', asset: 'USDC', amount: '0.01', payTo: REQ.payTo, resource: '/api/answer', nonce: 'n' }; + + it('accepts a matching payment and returns a receipt', async () => { + const r = await v.verify(payload(), required); + expect(r.ok).toBe(true); + expect(r.txRef).toBeTruthy(); + expect(r.from).toBe('0x2222222222222222222222222222222222222222'); + }); + it('accepts an overpayment', async () => { + expect((await v.verify(payload({ amount: '1.0' }), required)).ok).toBe(true); + }); + it('rejects wrong network / asset / payTo / underpayment', async () => { + expect((await v.verify(payload({ network: 'ethereum' }), required)).ok).toBe(false); + expect((await v.verify(payload({ asset: 'DAI' }), required)).ok).toBe(false); + expect((await v.verify(payload({ payTo: '0x9999999999999999999999999999999999999999' }), required)).ok).toBe(false); + const under = await v.verify(payload({ amount: '0.001' }), required); + expect(under.ok).toBe(false); + expect(under.reason).toMatch(/insufficient/); + }); + it('matches payTo case-insensitively', async () => { + expect((await v.verify(payload({ payTo: REQ.payTo.toUpperCase().replace('0X', '0x') }), required)).ok).toBe(true); + }); +}); + +describe('dRAG payment — resolvePayment gate', () => { + const v = new MockPaymentVerifier(); + const base = { network: 'base-sepolia', payTo: REQ.payTo, resource: '/api/answer', nonce: 'n', verifier: v }; + + it('is FREE when no price is configured', async () => { + const r = await resolvePayment({ ...base, price: undefined, xPaymentHeader: undefined }); + expect(r.kind).toBe('free'); + }); + it('CHALLENGES (402) when priced but no payment present', async () => { + const r = await resolvePayment({ ...base, price: { amount: '0.01', asset: 'USDC' }, xPaymentHeader: undefined }); + expect(r.kind).toBe('challenge'); + if (r.kind === 'challenge') expect(r.required.amount).toBe('0.01'); + }); + it('PAYS through when a valid payment is presented', async () => { + const header = encodeXPaymentHeader(payload()); + const r = await resolvePayment({ ...base, price: { amount: '0.01', asset: 'USDC' }, xPaymentHeader: header }); + expect(r.kind).toBe('paid'); + if (r.kind === 'paid') expect(r.receipt.ok).toBe(true); + }); + it('re-CHALLENGES when the presented payment is invalid (underpayment)', async () => { + const header = encodeXPaymentHeader(payload({ amount: '0.0001' })); + const r = await resolvePayment({ ...base, price: { amount: '0.01', asset: 'USDC' }, xPaymentHeader: header }); + expect(r.kind).toBe('challenge'); + if (r.kind === 'challenge') expect(r.reason).toMatch(/insufficient/); + }); +}); diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 5cd26f723..2d9f34e1b 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -52,6 +52,11 @@ export const PROTOCOL_ACCESS = '/dkg/10.0.1/private-access'; // at the app layer so this is semantically safe (see // docs/messenger.md "Response caching policy"). export const PROTOCOL_QUERY_REMOTE = '/dkg/10.0.1/query-remote'; +// OT-RFC-55 dRAG (P3): a peer asks this node to answer a question over a PUBLIC +// context graph and return grounded, verifiable citations. Same JSON-over- +// messenger pattern as PROTOCOL_QUERY_REMOTE; the serving node answers only for +// CGs whose live on-chain accessPolicy is public (fail-closed). +export const PROTOCOL_DRAG_ANSWER = '/dkg/10.0.1/drag-answer'; // rc.9 PR-8: bumped from /dkg/10.0.0/swm-sender-key to opt into the // Universal Messenger substrate (same rationale as PROTOCOL_ACCESS). // SWM sender-key send sites in dkg-agent.ts route through diff --git a/packages/core/src/crypto/citation.ts b/packages/core/src/crypto/citation.ts new file mode 100644 index 000000000..679560701 --- /dev/null +++ b/packages/core/src/crypto/citation.ts @@ -0,0 +1,207 @@ +/** + * Verifiable citation — the audit primitive behind dRAG (OT-RFC-55 §5.3 / + * OT-RFC-54 Phase 1 "verify-on-read"). + * + * A {@link VerifiableCitation} binds a single cited triple `(s, p, o)` to its + * source Knowledge Asset's **on-chain Merkle root**, so a consumer can confirm — + * without trusting the serving node — that the fact really is part of the sealed, + * anchored KA the citation claims, authored by the address the chain recorded. + * + * Three independent checks (see {@link CitationChecks}): + * - **merkle** — the cited triple's `keccak256(content)` leaf walks its V10 + * Merkle proof to the KA's structured root (content-binding: + * the leaf MUST equal `keccak256(content)` and the content MUST + * equal the cited triple's canonical N-Triple bytes, so a node + * cannot echo the root or swap a different triple's content). + * - **onChain** — that recomputed root equals the live on-chain + * `getLatestMerkleRoot(kaId)` (re-anchor; done where a chain + * adapter is available — see `@origintrail-official/dkg-agent`). + * - **authorSig**— the EIP-712 author seal recovers to the on-chain author + * (`getLatestMerkleRootAuthor(kaId)`); needs secp256k1 recovery + * (ethers), so it is performed in the agent layer. + * + * This module is the PURE, dependency-light half: the wire types, the hex codecs, + * and {@link verifyCitationProof} (the merkle + content-binding check, reusing + * {@link verifyV10ProofMaterial}). The producer ({@link VerifiableCitation} from a + * local triple store) and the author-seal recovery live in the agent package, + * which has the store, the chain adapter, and `ethers`. + * + * The proof carries the SAME V10 structured tree the Random Sampling prover and + * the on-chain `submitProof` verify use (keccak `hashTripleV10`, NOT the sha256 + * oracle path) — so a citation that verifies here verifies on-chain by + * construction. + */ + +import { tripleContentV10 } from './canonicalize.js'; +import { + verifyV10ProofMaterial, + type V10ProofMaterial, + type V10MerkleCommitment, +} from './proof-material.js'; + +/** A single `(subject, predicate, object)` triple cited as grounding for an answer. */ +export interface CitationTriple { + subject: string; + predicate: string; + object: string; +} + +/** + * Merkle inclusion proof binding a cited triple to its KA's on-chain root. + * Bytes are hex-encoded (`0x…`) so the citation is JSON-serialisable over the wire. + */ +export interface CitationProof { + /** `0x`-hex of the canonical N-Triple content bytes; chain derives `leaf = keccak256(content)`. */ + content: string; + /** `0x`-hex `keccak256(content)` — the leaf at `chunkId` after V10 sort+dedupe. */ + leaf: string; + /** `0x`-hex Merkle siblings, leaf→root; the public path ends with `privateDataHash`. */ + siblings: string[]; + /** Leaf index in the post sort+dedupe public tree (the on-chain `chunkId` space). */ + chunkId: number; + /** Deduped public-leaf count (the on-chain `merkleLeafCount`). */ + leafCount: number; +} + +/** + * EIP-712 author seal (compact signature, EIP-2098) — lets a verifier recover the + * author off-chain and confirm it matches the on-chain author, without trusting + * the chain read. Mirrors {@link parseAssertionSealQuads} output (the `_meta` seal). + */ +export interface CitationSeal { + /** `0x`-hex; MUST equal `onChain.merkleRoot`. */ + merkleRoot: string; + authorAddress: string; + /** `0x`-hex compact-sig `r`. */ + r: string; + /** `0x`-hex compact-sig `vs` (`yParityAndS`). */ + vs: string; + schemeVersion: number; + /** decimal string. */ + chainId: string; + kav10Address: string; + /** decimal string — the packed `reservedKaId` bound into the EIP-712 digest. */ + reservedKaId: string; +} + +export interface CitationChecks { + /** keccak Merkle inclusion holds AND content binds to the cited triple. */ + merkle: boolean; + /** + * Proof root equals the live on-chain `getLatestMerkleRoot(kaId)`. + * `null` ⇒ not re-anchored (the carried root was trusted, e.g. pure offline verify). + */ + onChain: boolean | null; + /** + * EIP-712 author seal recovers to the on-chain author. + * `null` ⇒ not checked (no seal present, or no secp256k1 recovery available). + */ + authorSig: boolean | null; + /** `merkle && onChain !== false && authorSig !== false`. */ + verified: boolean; +} + +/** + * A cited fact a consumer can audit against the chain. Produced by a serving node + * (which holds the KA + a chain adapter); verifiable by anyone. + */ +export interface VerifiableCitation { + /** UAL of the source Knowledge Asset (`did:dkg:context-graph:…`). */ + ual: string; + /** On-chain numeric KA id (packed `reservedKaId`), decimal string. */ + kaId: string; + /** On-chain numeric Context Graph id, decimal string. */ + contextGraphId: string; + /** The peer that served this citation (libp2p peerId), or `"local"`. */ + servingNode: string; + /** The cited triple. */ + triple: CitationTriple; + /** Merkle inclusion proof material (hex-encoded). */ + proof: CitationProof; + /** The on-chain anchor this proof verifies against. */ + onChain: { + /** `0x`-hex — `getLatestMerkleRoot(kaId)`. */ + merkleRoot: string; + /** `getLatestMerkleRootAuthor(kaId)`. */ + author: string; + /** decimal string. */ + chainId: string; + }; + /** EIP-712 author seal, when the `_meta` seal was resolvable. */ + seal?: CitationSeal; + /** Verification verdict (computed at production time; independently re-checkable). */ + checks: CitationChecks; +} + +// ── hex codecs ────────────────────────────────────────────────────────────── + +/** Encode bytes as a `0x`-prefixed lowercase hex string. */ +export function bytesToHex0x(bytes: Uint8Array): string { + let out = '0x'; + for (const b of bytes) out += b.toString(16).padStart(2, '0'); + return out; +} + +/** Decode a `0x`-prefixed (or bare) hex string into bytes. Throws on odd/invalid input. */ +export function hex0xToBytes(hex: string): Uint8Array { + const h = hex.startsWith('0x') || hex.startsWith('0X') ? hex.slice(2) : hex; + if (h.length % 2 !== 0) throw new Error(`hex string has odd length: ${hex}`); + const out = new Uint8Array(h.length / 2); + for (let i = 0; i < out.length; i++) { + const byte = parseInt(h.slice(i * 2, i * 2 + 2), 16); + if (Number.isNaN(byte)) throw new Error(`invalid hex byte in: ${hex}`); + out[i] = byte; + } + return out; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +// ── proof reconstruction + verification ─────────────────────────────────────── + +/** + * Rebuild the {@link V10ProofMaterial} + {@link V10MerkleCommitment} from a wire + * {@link CitationProof} and a root, so {@link verifyV10ProofMaterial} can re-check it. + */ +export function citationProofToMaterial( + proof: CitationProof, + merkleRootHex: string, +): { material: V10ProofMaterial; commitment: V10MerkleCommitment } { + const merkleRoot = hex0xToBytes(merkleRootHex); + const material: V10ProofMaterial = { + content: hex0xToBytes(proof.content), + leaf: hex0xToBytes(proof.leaf), + proof: proof.siblings.map(hex0xToBytes), + merkleRoot, + leafCount: proof.leafCount, + }; + const commitment: V10MerkleCommitment = { merkleRoot, merkleLeafCount: proof.leafCount }; + return { material, commitment }; +} + +/** + * PURE Merkle + content-binding verification of a citation against its carried + * on-chain root. Returns `true` iff: + * 1. `proof.content` is exactly the canonical N-Triple bytes of `citation.triple` + * (the proof is bound to THIS triple, not some other leaf), AND + * 2. {@link verifyV10ProofMaterial} accepts — i.e. `leaf == keccak256(content)` + * (content-binding), the recomputed root equals `onChain.merkleRoot`, and the + * leaf walks its siblings to that root at `chunkId`. + * + * Does NOT re-fetch the root from chain (that's the `onChain` re-anchor, performed + * by the agent verifier) and does NOT check the author seal (needs secp256k1). + */ +export function verifyCitationProof(citation: VerifiableCitation): boolean { + const expectedContent = tripleContentV10( + citation.triple.subject, + citation.triple.predicate, + citation.triple.object, + ); + if (!bytesEqual(expectedContent, hex0xToBytes(citation.proof.content))) return false; + const { material, commitment } = citationProofToMaterial(citation.proof, citation.onChain.merkleRoot); + return verifyV10ProofMaterial(material, citation.proof.chunkId, commitment); +} diff --git a/packages/core/src/crypto/index.ts b/packages/core/src/crypto/index.ts index f16105b95..3328b5e4a 100644 --- a/packages/core/src/crypto/index.ts +++ b/packages/core/src/crypto/index.ts @@ -127,3 +127,15 @@ export { } from './v10-publish-payload.js'; export { resolveRootEntities, type Quad as RootEntityQuad } from './root-entity.js'; + +export { + bytesToHex0x, + hex0xToBytes, + citationProofToMaterial, + verifyCitationProof, + type CitationTriple, + type CitationProof, + type CitationSeal, + type CitationChecks, + type VerifiableCitation, +} from './citation.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9805c0c89..424c5ab44 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -225,6 +225,7 @@ export { ExtractionPipelineRegistry, } from './extraction-pipeline.js'; export * from './transducers.js'; +export { RateLimiter, type RateLimitConfig } from './rate-limiter.js'; export { ASSERTION_SEAL_PREDICATES, ASSERTION_PUBLISH_RECEIPT_PREDICATES, diff --git a/packages/core/test/citation.test.ts b/packages/core/test/citation.test.ts new file mode 100644 index 000000000..eeff6966f --- /dev/null +++ b/packages/core/test/citation.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest'; +import { + buildV10ProofMaterial, + structuredKARootV10, + tripleContentV10, + hashTripleV10, + keccak256, + bytesToHex0x, + hex0xToBytes, + verifyCitationProof, + citationProofToMaterial, + type CitationTriple, + type VerifiableCitation, +} from '../src/index.js'; + +// Pure (no chain / no ethers) verification of the dRAG verifiable-citation +// Merkle path. A citation that verifies here verifies on-chain by construction, +// because it uses the SAME structured V10 tree (`structuredKARootV10` / +// `buildV10ProofMaterial`) as the publisher and the on-chain `submitProof`. + +const TRIPLES: CitationTriple[] = [ + { subject: 'urn:drag:fact-supplier', predicate: 'http://schema.org/name', object: '"Northwind Components"' }, + { subject: 'urn:drag:fact-supplier', predicate: 'http://schema.org/auditStatus', object: '"flagged"' }, + { subject: 'urn:drag:fact-supplier', predicate: 'http://schema.org/incidents', object: '"3"' }, + { subject: 'urn:drag:fact-supplier', predicate: 'http://schema.org/region', object: '"EU"' }, +]; + +function bytesEq(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +/** + * Assemble a VerifiableCitation for `triples[citedIndex]` exactly the way the + * agent producer would, but over a synthetic in-test KA whose "on-chain" root is + * the structured V10 root of the triple set (private roots empty). + */ +function synthCitation(triples: CitationTriple[], citedIndex: number): VerifiableCitation { + const contents = triples.map((t) => tripleContentV10(t.subject, t.predicate, t.object)); + const leaves = contents.map((c) => keccak256(c)); + const { root, leafCount, publicTree } = structuredKARootV10(leaves, []); + + const cited = triples[citedIndex]; + const citedLeaf = hashTripleV10(cited.subject, cited.predicate, cited.object); + let chunkId = -1; + for (let i = 0; i < publicTree.leafCount; i++) { + if (bytesEq(publicTree.leafAt(i), citedLeaf)) { chunkId = i; break; } + } + expect(chunkId, 'cited triple must be a leaf').toBeGreaterThanOrEqual(0); + + const material = buildV10ProofMaterial(contents, [], chunkId, { + merkleRoot: root, + merkleLeafCount: leafCount, + }); + + return { + ual: 'did:dkg:context-graph:drag-test/0x00/1', + kaId: '1', + contextGraphId: '3', + servingNode: 'local', + triple: cited, + proof: { + content: bytesToHex0x(material.content), + leaf: bytesToHex0x(material.leaf), + siblings: material.proof.map(bytesToHex0x), + chunkId, + leafCount: material.leafCount, + }, + onChain: { + merkleRoot: bytesToHex0x(root), + author: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + chainId: '31337', + }, + checks: { merkle: true, onChain: true, authorSig: null, verified: true }, + }; +} + +describe('verifiable citation — pure Merkle proof', () => { + it('verifies a citation for every triple in the KA', () => { + for (let i = 0; i < TRIPLES.length; i++) { + const c = synthCitation(TRIPLES, i); + expect(verifyCitationProof(c), `triple ${i} should verify`).toBe(true); + } + }); + + it('content-binds the proof to the cited triple (swapping the object fails)', () => { + const c = synthCitation(TRIPLES, 0); + const tampered: VerifiableCitation = { + ...c, + triple: { ...c.triple, object: '"Globex Materials"' }, + }; + // The proof.content still encodes the original triple, so the cited-triple + // content check fails before the merkle walk. + expect(verifyCitationProof(tampered)).toBe(false); + }); + + it('rejects a forged content payload that does not hash to the proven leaf', () => { + const c = synthCitation(TRIPLES, 0); + const forged: VerifiableCitation = { + ...c, + triple: { ...c.triple, object: '"Tampered"' }, + proof: { ...c.proof, content: bytesToHex0x(tripleContentV10(c.triple.subject, c.triple.predicate, '"Tampered"')) }, + }; + // Content now matches the (tampered) triple, but its keccak leaf is not in + // the tree under the on-chain root → merkle walk fails. + expect(verifyCitationProof(forged)).toBe(false); + }); + + it('rejects a citation whose on-chain root has been altered', () => { + const c = synthCitation(TRIPLES, 1); + const wrongRoot = new Uint8Array(32).fill(0xab); + const tampered: VerifiableCitation = { + ...c, + onChain: { ...c.onChain, merkleRoot: bytesToHex0x(wrongRoot) }, + }; + expect(verifyCitationProof(tampered)).toBe(false); + }); + + it('rejects a citation with a corrupted sibling in the proof path', () => { + const c = synthCitation(TRIPLES, 2); + const siblings = [...c.proof.siblings]; + expect(siblings.length).toBeGreaterThan(0); + siblings[0] = bytesToHex0x(new Uint8Array(32).fill(0x01)); + const tampered: VerifiableCitation = { ...c, proof: { ...c.proof, siblings } }; + expect(verifyCitationProof(tampered)).toBe(false); + }); + + it('round-trips hex codecs', () => { + const b = keccak256(new TextEncoder().encode('drag')); + expect(bytesEq(hex0xToBytes(bytesToHex0x(b)), b)).toBe(true); + }); + + it('citationProofToMaterial reconstructs the on-chain commitment', () => { + const c = synthCitation(TRIPLES, 0); + const { commitment } = citationProofToMaterial(c.proof, c.onChain.merkleRoot); + expect(commitment.merkleLeafCount).toBe(c.proof.leafCount); + expect(bytesToHex0x(commitment.merkleRoot)).toBe(c.onChain.merkleRoot); + }); + + it('a single-triple KA (one leaf) still produces a verifiable citation', () => { + const single = [TRIPLES[0]]; + const c = synthCitation(single, 0); + expect(verifyCitationProof(c)).toBe(true); + }); +}); diff --git a/packages/mcp-dkg/src/client.ts b/packages/mcp-dkg/src/client.ts index cd75abb29..ea5e3f868 100644 --- a/packages/mcp-dkg/src/client.ts +++ b/packages/mcp-dkg/src/client.ts @@ -26,6 +26,39 @@ export interface QueryResponse { phases?: Record; } +/** A verifiable citation in a dRAG answer (subset surfaced to the MCP layer). */ +export interface DragCitation { + ual: string; + kaId: string; + contextGraphId: string; + servingNode: string; + triple: { subject: string; predicate: string; object: string }; + onChain: { merkleRoot: string; author: string; chainId: string }; + checks: { merkle: boolean; onChain: boolean | null; authorSig: boolean | null; verified: boolean }; +} + +/** Response of POST /api/answer (dRAG, OT-RFC-55). `scope: "network"` adds `perNode`. */ +export interface DragAnswerResult { + question: string; + contextGraphId: string; + scope: string; + answer: string; + llm: boolean; + citations: DragCitation[]; + facts: Array<{ subject: string; predicate: string; object: string; source: number }>; + perNode?: Array<{ peerId: string; factsCited: number; verified: number; error?: string }>; + /** x402 settlement receipt, present when the answer was paid for. */ + settlement?: { ok: boolean; asset: string; amount: string; payTo: string; txRef?: string }; + stats: { + keywords: string[]; + factsCited: number; + verified: number; + kasMatched?: number; + servingNodes?: number; + nodesAnswered?: number; + }; +} + export interface ProjectRow { id: string; name?: string; @@ -433,6 +466,26 @@ export class DkgClient { return r.result ?? { bindings: [] }; } + /** + * dRAG single-node grounded answer (OT-RFC-55). Returns a keyword/structural + * answer over one Context Graph's verifiable memory, with a verifiable + * citation per cited fact. POST /api/answer. + */ + async answer(args: { + question: string; + contextGraphId?: string; + scope?: 'local' | 'network'; + maxCitations?: number; + maxKas?: number; + }): Promise { + const body: Record = { question: args.question }; + if (args.contextGraphId) body.contextGraphId = args.contextGraphId; + if (args.scope) body.scope = args.scope; + if (args.maxCitations != null) body.maxCitations = args.maxCitations; + if (args.maxKas != null) body.maxKas = args.maxKas; + return this.request('POST', '/api/answer', body); + } + /** * Fetch the daemon's default agent identity. Used by `dkg_memory_search` * to resolve the agent address required for WM view routing — the diff --git a/packages/mcp-dkg/src/index.ts b/packages/mcp-dkg/src/index.ts index cee53d86d..fb9d4d4c7 100644 --- a/packages/mcp-dkg/src/index.ts +++ b/packages/mcp-dkg/src/index.ts @@ -18,6 +18,7 @@ import { DkgClient } from './client.js'; import { registerReadTools } from './tools.js'; import { registerAssertionTools } from './tools/assertions.js'; import { registerMemorySearchTool } from './tools/memory-search.js'; +import { registerAnswerTool } from './tools/answer.js'; import { registerSetupTools } from './tools/setup.js'; import { registerHealthTools } from './tools/health.js'; import { registerPublishTools } from './tools/publish.js'; @@ -56,6 +57,7 @@ export async function main(argv: string[] = process.argv): Promise { registerReadTools(server, client, config); registerAssertionTools(server, client, config); registerMemorySearchTool(server, client, config); + registerAnswerTool(server, client, config); registerSetupTools(server, client, config); registerHealthTools(server, client, config); registerPublishTools(server, client, config); diff --git a/packages/mcp-dkg/src/tools/answer.ts b/packages/mcp-dkg/src/tools/answer.ts new file mode 100644 index 000000000..20e07cebe --- /dev/null +++ b/packages/mcp-dkg/src/tools/answer.ts @@ -0,0 +1,103 @@ +/** + * `dkg_answer` — ask a natural-language question and get a GROUNDED answer whose + * every fact is backed by a verifiable citation (OT-RFC-55 P2, single-node). + * + * Unlike `dkg_memory_search` (which returns ranked snippets) and + * `dkg_get_entity_sources` (which needs a known entity URI), `dkg_answer` chains + * question → grounded facts → citations end to end. Each citation is auditable + * against the chain: V10 Merkle inclusion + on-chain root + EIP-712 author seal. + * Retrieval is keyword/structural — no node-side LLM required. + * + * Thin wrapper over `DkgClient.answer()` → `POST /api/answer`. + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { DkgClient, DragAnswerResult } from '../client.js'; +import type { DkgConfig } from '../config.js'; + +type ToolResult = { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +}; + +const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); +const err = (text: string): ToolResult => ({ content: [{ type: 'text', text }], isError: true }); +const formatError = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +function resolveProject(explicit: string | undefined, config: DkgConfig): string | null { + return explicit ?? config.defaultProject ?? null; +} + +function summarize(r: DragAnswerResult): string { + const { factsCited, verified } = r.stats; + if (factsCited === 0) return r.answer; + const trust = + verified === factsCited + ? `All ${factsCited} citation${factsCited === 1 ? '' : 's'} verified against the chain ✓` + : `${verified}/${factsCited} citations verified against the chain`; + const net = + r.scope === 'network' + ? ` · network: ${r.stats.nodesAnswered ?? 0}/${r.stats.servingNodes ?? 0} serving nodes` + : ''; + const paid = r.settlement?.ok + ? ` · paid ${r.settlement.amount} ${r.settlement.asset} (x402)` + : ''; + const header = `> ${trust} · context graph \`${r.contextGraphId}\`${r.llm ? ' · LLM-synthesised' : ' · keyword-grounded'}${net}${paid}\n`; + return `${header}\n${r.answer}`; +} + +export function registerAnswerTool(server: McpServer, client: DkgClient, config: DkgConfig): void { + server.registerTool( + 'dkg_answer', + { + title: 'Answer (grounded + verifiable citations)', + description: + 'Ask a natural-language question about a context graph and get a grounded ' + + 'answer where every fact carries a VERIFIABLE citation (V10 Merkle ' + + 'inclusion + on-chain anchor + EIP-712 author seal). Use this when you need ' + + 'an answer you can AUDIT — each cited fact is bound to a sealed, on-chain ' + + 'Knowledge Asset, so a fabricated or tampered citation fails verification. ' + + 'Retrieval is keyword-based over the verifiable memory of one context graph ' + + 'on this node (no LLM required). For ranked snippet recall use ' + + '`dkg_memory_search`; for the sources of a KNOWN entity use ' + + '`dkg_get_entity_sources`.', + inputSchema: { + question: z.string().min(1).describe('The natural-language question to answer.'), + projectId: z + .string() + .optional() + .describe('Context graph to answer over. Defaults to the pinned project.'), + scope: z + .enum(['local', 'network']) + .optional() + .describe( + 'Where to answer from. "local" (default) = this node\'s copy of the ' + + 'context graph. "network" = fan out to every node serving the (public) ' + + 'context graph and aggregate their citations, each re-verified against ' + + 'the chain — use it to answer over knowledge this node may not hold.', + ), + maxCitations: z + .number() + .int() + .positive() + .max(50) + .optional() + .describe('Cap on cited facts (default 12).'), + }, + }, + async ({ question, projectId, scope, maxCitations }): Promise => { + const cg = resolveProject(projectId, config); + if (!cg) { + return err( + 'No context graph specified. Pass `projectId`, set `DKG_PROJECT`, or pin `contextGraph:` in `.dkg/config.yaml`.', + ); + } + try { + const result = await client.answer({ question, contextGraphId: cg, scope, maxCitations }); + return ok(summarize(result)); + } catch (e) { + return err(`dkg_answer failed: ${formatError(e)}`); + } + }, + ); +}