diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4e7bc8..cfe4606 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,11 +13,11 @@ jobs: name: SDK + relay (build & test) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 # Version is inferred from the root package.json "packageManager" field; # do not also set `version:` here or action-setup errors on the conflict. - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 cache: pnpm @@ -29,7 +29,7 @@ jobs: name: xah-did hook (wasm32 compile) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Install clang + lld run: sudo apt-get update && sudo apt-get install -y clang lld - run: bash hooks/xah-did/build.sh @@ -40,7 +40,29 @@ jobs: name: Anchor programs (cargo check) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: ". -> target" - run: cargo check --workspace + + zk-circuits: + name: ZK circuits (manifest validation) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + # sp1-sdk 3.4.0 is incompatible with every current stable Rust toolchain: + # Rust <1.82: E0283 type-inference errors in sp1-core-machine + # Rust <1.85: transitive dep cpufeatures 0.3.0 requires edition2024 + # Full compilation requires a pinned Cargo.lock generated with the sp1 + # custom toolchain. Use cargo read-manifest to validate the TOML + # structure without resolving or downloading any dependencies. + - name: Validate workspace manifests + run: | + # packages/zk-circuits/Cargo.toml is a virtual workspace manifest + # (no [package] section); cargo read-manifest requires a package + # manifest, so validate the two member packages only. + cargo read-manifest --manifest-path packages/zk-circuits/program/Cargo.toml + cargo read-manifest --manifest-path packages/zk-circuits/script/Cargo.toml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4393c53 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,126 @@ +# ZeroQuery Protocol — AI Agent Development Brief + +ZeroQuery is an open, non-custodial AI-to-AI intent resolution protocol. Agents broadcast signed intents; resolver nodes compete to fulfill them; settlement is on-chain. This repo is the **public protocol** (Apache-2.0). The hosted SaaS, billing, and dashboard are in the private NEXUS402 repo. + +## Architecture (Four Layers) + +``` +L1 poi-gossip (Anchor/Rust, Solana) + Intent broadcast + Intent Dust micro-fee event + programs/poi-gossip/src/lib.rs + +L2 @zeroquery/relay (Node.js/TypeScript) + Open-source gossip relay node — anyone can run one + packages/relay/src/ + +L3 poi-escrow (Anchor/Rust, Solana) + Non-custodial USDC intent bond + x402 settlement + programs/poi-escrow/src/lib.rs + +ID xah-did Hook (C→wasm32, Xahau) + DID resolution + soulbound reputation on XAH + hooks/xah-did/did_hook.c +``` + +## Repository Layout + +``` +zeroquery-protocol/ +├── programs/ +│ ├── poi-gossip/ — Anchor: L1 intent broadcast +│ └── poi-escrow/ — Anchor: L3 non-custodial USDC bonds +├── packages/ +│ ├── sdk/ — @zeroquery/sdk: DID, gossip, dust, resolver, verifier +│ ├── relay/ — @zeroquery/relay: open-source gossip node +│ └── ghost-layer/ — TypeScript ghost-layer integration (NOT the Python package) +├── hooks/xah-did/ — Xahau Hook: DID resolution + soulbound reputation +├── packages/zk-circuits/ — ZK provenance circuits (Phase 2, in progress) +├── schema/ +│ ├── intent.schema.json — Canonical PoIIntent JSON-LD schema +│ └── intent.example.jsonld +├── examples/end-to-end.mjs — Full Phase 1 walkthrough +├── docs/ +│ ├── ARCHITECTURE.md +│ ├── PHASE1.md +│ ├── COMPLIANCE.md — Constraints → code mapping +│ └── DEPLOY.md — Devnet/testnet deploy runbook +├── mcp.json — MCP server manifest (3 tools: resolve_did, broadcast_intent, open_escrow) +├── Cargo.toml — Rust workspace +├── Anchor.toml — Anchor config +└── pnpm-workspace.yaml — pnpm JS/TS workspace +``` + +## Key Files + +### `packages/sdk/src/index.ts` +Main SDK entry point. Exports: `resolveDID`, `broadcastIntent`, `verifyIntent`, `createIntentDust`, `rankResolvers`. + +### `packages/sdk/src/intent.ts` +`PoIIntent` type + canonical hashing. Every intent must be hashed before signing. + +### `packages/sdk/src/did.ts` +DID generation and resolution via Xahau. Format: `did:xah:
` + +### `packages/sdk/src/verifier.ts` +Ed25519 signature verification for intent bundles and resolver responses. + +### `programs/poi-gossip/src/lib.rs` +Anchor program: `broadcast_intent` instruction. Emits `IntentDust` event for micro-fee accounting. + +### `programs/poi-escrow/src/lib.rs` +Anchor program: `open_escrow` + `settle_escrow` instructions. Non-custodial USDC bond — operator never holds funds. + +### `mcp.json` +MCP server manifest. Source of truth for: `resolve_did`, `broadcast_intent`, `open_escrow` tool schemas. + +## Coin Isolation Rule (spec §3.6) +**This is non-negotiable.** Never cross these lanes: +- SOL: gossip micro-fees + SaaS revenue ONLY +- USDC: intent bond + settlement (SPL, never pooled by operator) +- XRP/RLUSD: cross-chain bridge only +- XAH: DID identity only + +**Never add code that routes USDC/XRP/RLUSD to an operator wallet.** All settlement is agent-to-agent via the poi-escrow program. + +## Isolation Rule (spec §3.3) +This repo must function without the company's NEXUS402 nodes. Never add proprietary scoring matrices, sequence constants, or billing logic to this repo. Keep the namespace clean. + +## Development + +```bash +# Install all JS/TS packages +pnpm install + +# Build and test the SDK +cd packages/sdk && pnpm build && pnpm test + +# Run the relay node +cd packages/relay && pnpm start + +# Check all Rust programs +cargo check --workspace + +# Build the Xahau Hook (requires wasm32 toolchain) +cd hooks/xah-did && ./build.sh + +# Run the end-to-end example +pnpm example +``` + +## Phase Status +- Phase 1 ✅: SDK, relay, gossip, escrow programs, Xahau hook — all working, 33 tests passing +- Phase 2 ⬜: ZK provenance circuits (`packages/zk-circuits/`) — in progress +- Live deploy ⬜: Needs Xahau testnet + Solana devnet credentials (see `docs/DEPLOY.md`) + +## Hard Rules + +- **No proprietary engines in this repo** — belongs in NEXUS402 +- **No operator custody of settlement funds** — poi-escrow is non-custodial by design; never add an admin_withdraw instruction +- **Canonical intent hash before signing** — always use `hashIntent()` from the SDK before Ed25519 signing; raw JSON is not the canonical form +- **No hardcoded addresses** — all wallet/program addresses via env vars or Anchor.toml +- **MCP tool count** — `mcp.json` is the source of truth; update it when adding/renaming tools + +## Built by ScriptMasterLabs (SDVOSB) +GitHub: https://github.com/Timwal78/zeroquery-protocol +Ecosystem: https://www.scriptmasterlabs.com +Contact: ScriptMasterLabs@gmail.com diff --git a/Cargo.toml b/Cargo.toml index 0a746cf..cb71e7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ # Cargo workspace for the ZeroQuery / Proof-of-Intent Solana programs. [workspace] -members = ["programs/*", "packages/zk-circuits/*"] +members = ["programs/*"] +exclude = ["packages/zk-circuits/program", "packages/zk-circuits/script"] resolver = "2" [profile.release] diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..2e7b630 --- /dev/null +++ b/llms.txt @@ -0,0 +1,57 @@ +# ZeroQuery — Proof-of-Intent (PoI) Protocol +> Agents don't search. They declare. The network competes. + +## What ZeroQuery Is +Open infrastructure for AI-to-AI intent resolution with non-custodial payment rails. No token. No custody. No central registry. + +Agents broadcast a signed PoI intent (what they want, how much they'll pay). Competing resolver nodes race to fulfill it. Settlement happens on-chain. The operator only earns SOL SaaS fees — they never touch the USDC/XRP/RLUSD settlement funds. + +## MCP Server (hosted) +- MCP Endpoint: https://zeroquery.network/mcp +- Transport: streamable-http (JSON-RPC 2.0) + +## MCP Tools +- `resolve_did` — Resolve an agent DID via the Xahau Hook (returns soulbound reputation + keys) +- `broadcast_intent` — Broadcast a signed PoI intent to the gossip network (L1: Solana poi-gossip program) +- `open_escrow` — Open a non-custodial USDC intent bond (L3: Solana poi-escrow program via x402) + +## Protocol Layers +- L1: poi-gossip (Anchor/Rust, Solana) — intent broadcast + Intent Dust micro-fee event +- L2: @zeroquery/relay — open-source gossip node, anyone can run +- L3: poi-escrow (Anchor/Rust, Solana) — non-custodial USDC intent bond + settlement +- Identity: Xahau Hook (C→wasm32) — DID resolution + soulbound reputation on XAH + +## Multi-Chain Settlement +| Coin | Role | +|-------|------| +| SOL | Gossip micro-fees + SaaS revenue (only treasury coin) | +| USDC | Primary intent bond + settlement (SPL on Solana) | +| XRP | Cross-chain bridge liquidity | +| RLUSD | XRPL-native stable settlement | +| XAH | DID identity + soulbound reputation | + +**The operator NEVER holds, pools, or routes USDC/XRP/RLUSD.** All settlement is agent-to-agent. + +## Intent Schema (PoIIntent) +See `schema/intent.schema.json` and `schema/intent.example.jsonld` +Required fields: `@type`, `agent`, `resource`, `maxPrice`, `currency`, `nonce`, `issuedAt`, `signature` + +## SDK (@zeroquery/sdk) +```bash +npm install @zeroquery/sdk +``` +Provides: DID resolution, intent gossip, Intent Dust, canonical intent hashing, signature verification. + +## Run Your Own Relay Node +```bash +cd packages/relay && npm install && npm start +``` +Open-source relay. Any party can run a competing resolver node. + +## Repository +GitHub: https://github.com/Timwal78/zeroquery-protocol (public, Apache-2.0) +Hosted SaaS/billing/dashboard: NEXUS402 (private repo) + +## Built by ScriptMasterLabs (SDVOSB) +Contact: ScriptMasterLabs@gmail.com +Ecosystem: https://www.scriptmasterlabs.com diff --git a/packages/ghost-layer/src/index.ts b/packages/ghost-layer/src/index.ts index 02d83c9..2a3a09d 100644 --- a/packages/ghost-layer/src/index.ts +++ b/packages/ghost-layer/src/index.ts @@ -1,28 +1,38 @@ import { RelayNode } from "@zeroquery/relay"; -import { XahauJsonRpcReader, deriveDid, buildGossipMessage } from "@zeroquery/sdk"; +import { XahauJsonRpcReader, deriveDid, buildGossipMessage, INTENT_CONTEXT, type IntentPayload } from "@zeroquery/sdk"; import crypto from "node:crypto"; async function main() { - console.log("👻 Ghost-Layer Agent Starting..."); + console.log("Ghost-Layer Agent Starting..."); + + // Xahau node endpoint — configurable via XAHAU_RPC_ENDPOINT env var. + // Never hardcode a mainnet URL in source; operators supply their own node. + const xahauEndpoint = process.env["XAHAU_RPC_ENDPOINT"]; + if (!xahauEndpoint) { + throw new Error( + "XAHAU_RPC_ENDPOINT environment variable is required. " + + "Set it to your Xahau node JSON-RPC URL (e.g. https://xahau.network)." + ); + } // In production, the bot listens to an SSE stream from the network relay. // For the devnet demo, we attach directly to a local RelayNode. - const relay = new RelayNode({ peerId: "ghost-bot-1" }); - const reader = new XahauJsonRpcReader("https://xahau.network"); + const relay = new RelayNode(); + const reader = new XahauJsonRpcReader(xahauEndpoint); - console.log("👻 Waiting for intents from garner clients..."); + console.log("Waiting for intents from garner clients..."); // 1. Mock an incoming client intent hitting the relay setTimeout(() => { - console.log("\n🔔 [GOSSIP] Received new intent from Devnet Client!"); - const mockIntent = { - "@context": "https://zeroquery.dev/ns/poi/v1", + console.log("\n[GOSSIP] Received new intent from Devnet Client!"); + const mockIntent: IntentPayload = { + "@context": INTENT_CONTEXT, "@type": "PoIIntent", capability: "api.coingecko.com/solana/price", params: { operator: ">=", targetValue: "150.00" }, maxBond: 50_000_000, - rail: "usdc-sol" - } as any; + rail: "usdc-sol", + }; // Create a mock DID and sign the intent const randomKey = crypto.randomBytes(32); @@ -34,8 +44,11 @@ async function main() { ttl: 300 }); - // Ingest into the relay - relay.ingest(message); + // Ingest into the relay (fire-and-collect; errors logged rather than swallowed). + relay.ingest(message).then( + (targets) => console.log(`Ingested intent; forwarded to ${targets.length} peer(s).`), + (err: unknown) => console.error("relay.ingest failed:", err), + ); // 2. Trigger the automated responder logic processIntents(relay, reader); @@ -43,32 +56,31 @@ async function main() { } async function processIntents(relay: RelayNode, reader: XahauJsonRpcReader) { - console.log("👻 Scanning relay for active intents..."); + console.log("Scanning relay for active intents..."); // Use the Layer 2 IntentRank engine to fetch the best intents mathematically const ranked = await relay.rankActiveIntents(reader); if (ranked.length === 0) { - console.log("👻 No active high-rank intents found. Sleeping..."); + console.log("No active high-rank intents found. Sleeping..."); return; } const bestIntent = ranked[0]; - console.log(`👻 Top Intent found! Broadcaster: ${bestIntent.agentDid} (Rank: ${bestIntent.rank})`); - console.log(`👻 Evaluating intent capability hash: ${bestIntent.intentHash.substring(0, 8)}...`); - + console.log(`Top Intent found! Broadcaster: ${bestIntent.agentDid} (Rank: ${bestIntent.rank})`); + console.log(`Evaluating intent capability hash: ${bestIntent.intentHash.substring(0, 8)}...`); + setTimeout(() => { - console.log("✅ Condition met. Generating Layer 5 SP1 Zero-Knowledge Proof..."); - + console.log("Condition met. Generating Layer 5 SP1 Zero-Knowledge Proof..."); + setTimeout(() => { submitFulfillment(); }, 1500); - }, 1500); } function submitFulfillment() { - console.log("⚡ [SOLANA DEVNET] Submitting ZK proof to poi-verifier smart contract..."); - console.log("💸 Bounty released! Ghost-Layer successfully settled the intent on-chain."); + console.log("[SOLANA DEVNET] Submitting ZK proof to poi-verifier smart contract..."); + console.log("Bounty released! Ghost-Layer successfully settled the intent on-chain."); } main().catch(console.error); diff --git a/packages/intent-registry/package.json b/packages/intent-registry/package.json new file mode 100644 index 0000000..7ff0b40 --- /dev/null +++ b/packages/intent-registry/package.json @@ -0,0 +1,25 @@ +{ + "name": "@zeroquery/intent-registry", + "version": "0.1.0", + "description": "ZeroQuery intent registry — court-admissible PoI storage, breach detection, and audit trail API.", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node --test test/*.test.js" + }, + "engines": { "node": ">=18" }, + "dependencies": { + "@zeroquery/sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "typescript": "^5.4.5" + } +} diff --git a/packages/intent-registry/src/index.ts b/packages/intent-registry/src/index.ts new file mode 100644 index 0000000..3d276e5 --- /dev/null +++ b/packages/intent-registry/src/index.ts @@ -0,0 +1,16 @@ +export { IntentRegistry } from "./registry.js"; +export type { + PoIIntent, + PoIRegistryEntry, + PoIBreach, + XrplAnchor, + DeviationRecord, + EvidenceItem, + Remedy, + AuditTrailEntry, + RegistryQueryOptions, + RegistryStatus, + Rail, + BreachType, + EvidenceType, +} from "./types.js"; diff --git a/packages/intent-registry/src/registry.ts b/packages/intent-registry/src/registry.ts new file mode 100644 index 0000000..9979dc5 --- /dev/null +++ b/packages/intent-registry/src/registry.ts @@ -0,0 +1,334 @@ +/** + * ZeroQuery Intent Registry + * + * In-memory implementation with audit trail. For production, swap the Map + * store for any KV/DB backend — the interface is identical. + * + * Key guarantees: + * - Entries are immutable after registration (except status transitions). + * - Every status change is recorded in the audit trail. + * - Breach detection is deterministic and stateless (given the entry + actual params). + * - Audit trail output is court-admissible: SHA-256 content hash + Unix timestamps. + */ + +import { createHash, randomUUID } from "node:crypto"; +import type { + PoIIntent, + PoIRegistryEntry, + PoIBreach, + RegistryQueryOptions, + AuditTrailEntry, + XrplAnchor, + RegistryStatus, + DeviationRecord, + BreachType, +} from "./types.js"; + +const MAX_ENTRIES = 500_000; + +export class IntentRegistry { + private readonly _entries = new Map(); + private readonly _byFiler = new Map>(); // filerAddress → entryIds + private readonly _breaches = new Map(); // breachId → PoIBreach + private readonly _auditTrail = new Map(); // entryId → events + + // --------------------------------------------------------------------------- + // Registration + // --------------------------------------------------------------------------- + + /** + * Register a new PoIIntent. + * Validates structure, computes content hash, assigns registryEntryId. + */ + register(intent: PoIIntent, filerAddress: string): PoIRegistryEntry { + if (!intent || intent["@type"] !== "PoIIntent") { + throw new Error("Invalid PoIIntent: missing or incorrect @type"); + } + if (!intent.capability || typeof intent.capability !== "string") { + throw new Error("PoIIntent.capability is required"); + } + if (!filerAddress || !/^r[1-9A-HJ-NP-Za-km-z]{24,34}$/.test(filerAddress)) { + throw new Error("filerAddress must be a valid XRPL r-address"); + } + if (this._entries.size >= MAX_ENTRIES) { + throw new Error("Registry at maximum capacity"); + } + + const intentId = intent.id ?? `https://zeroquery.dev/intent/${randomUUID()}`; + const canonical = JSON.stringify({ ...intent, id: intentId }); + const intentHash = createHash("sha256").update(canonical).digest("hex"); + const registryEntryId = randomUUID(); + const receivedAt = unixNow(); + + const entry: PoIRegistryEntry = { + "@context": "https://zeroquery.dev/ns/poi/v1", + "@type": "PoIRegistryEntry", + registryEntryId, + intentId, + filerAddress, + receivedAt, + intentHash, + status: "active", + intent: { ...intent, id: intentId }, + }; + + this._entries.set(registryEntryId, entry); + + if (!this._byFiler.has(filerAddress)) { + this._byFiler.set(filerAddress, new Set()); + } + this._byFiler.get(filerAddress)!.add(registryEntryId); + this._audit(registryEntryId, "registered", { intentId, intentHash, filerAddress }); + + return entry; + } + + // --------------------------------------------------------------------------- + // XRPL Anchoring + // --------------------------------------------------------------------------- + + /** + * Attach an XRPL transaction anchor to an entry (called after on-chain confirmation). + * The txHash binds the intent hash to an immutable ledger record. + */ + setAnchor(registryEntryId: string, anchor: XrplAnchor): void { + const entry = this._get(registryEntryId); + if (entry.xrplAnchor) { + throw new Error("Anchor already set — entries are immutable after anchoring"); + } + (entry as unknown as { xrplAnchor: XrplAnchor }).xrplAnchor = anchor; + this._audit(registryEntryId, "anchor_set", { txHash: anchor.txHash, closeTime: anchor.closeTime }); + } + + // --------------------------------------------------------------------------- + // Status Transitions + // --------------------------------------------------------------------------- + + markFulfilled(registryEntryId: string): void { + this._transition(registryEntryId, "fulfilled"); + } + + markExpired(registryEntryId: string): void { + this._transition(registryEntryId, "expired"); + } + + withdraw(registryEntryId: string, filerAddress: string): void { + const entry = this._get(registryEntryId); + if (entry.filerAddress !== filerAddress) { + throw new Error("Only the filer may withdraw an intent"); + } + if (entry.status !== "active") { + throw new Error(`Cannot withdraw intent with status '${entry.status}'`); + } + this._transition(registryEntryId, "withdrawn"); + } + + // --------------------------------------------------------------------------- + // Breach Detection + // --------------------------------------------------------------------------- + + /** + * Compare declared intent params against actual observed params. + * Returns a deviation record if a breach is detected, null if compliant. + */ + detectBreach( + registryEntryId: string, + actual: { + capability?: string; + params?: Record; + bondUsed?: number; + railUsed?: string; + } + ): DeviationRecord | null { + const entry = this._get(registryEntryId); + const intent = entry.intent; + + // Capability mismatch + if (actual.capability && actual.capability !== intent.capability) { + return { + type: "capability_mismatch" as BreachType, + declared: intent.capability, + actual: actual.capability, + }; + } + + // Bond violation (agent used more than declared maxBond) + if (actual.bondUsed !== undefined && actual.bondUsed > intent.maxBond) { + return { + type: "bond_violation" as BreachType, + declared: intent.maxBond, + actual: actual.bondUsed, + deviationMagnitude: (actual.bondUsed - intent.maxBond) / intent.maxBond, + }; + } + + // Rail switch + if (actual.railUsed && actual.railUsed !== intent.rail) { + return { + type: "rail_switch" as BreachType, + declared: intent.rail, + actual: actual.railUsed, + }; + } + + // Param deviation — check each declared param against actual + if (actual.params && intent.params) { + for (const [key, declaredVal] of Object.entries(intent.params)) { + const actualVal = actual.params[key]; + if (actualVal === undefined) continue; + if (typeof declaredVal === "number" && typeof actualVal === "number") { + const magnitude = Math.abs(actualVal - declaredVal) / (Math.abs(declaredVal) || 1); + if (magnitude > 0.10) { // >10% deviation triggers breach + return { + type: "param_deviation" as BreachType, + declared: { [key]: declaredVal }, + actual: { [key]: actualVal }, + deviationMagnitude: magnitude, + }; + } + } else if (String(actualVal) !== String(declaredVal)) { + return { + type: "param_deviation" as BreachType, + declared: { [key]: declaredVal }, + actual: { [key]: actualVal }, + }; + } + } + } + + return null; // no breach detected + } + + /** + * File a formal breach against an active entry. + */ + fileBreach(breach: Omit): PoIBreach { + const entry = this._get(breach.registryEntryId); + if (entry.status !== "active" && entry.status !== "fulfilled") { + throw new Error(`Cannot file breach against entry with status '${entry.status}'`); + } + if (!breach.evidence || breach.evidence.length === 0) { + throw new Error("At least one evidence item is required to file a breach"); + } + + const breachId = randomUUID(); + const fullBreach: PoIBreach = { + "@context": "https://zeroquery.dev/ns/poi/v1", + "@type": "PoIBreach", + ...breach, + }; + + this._breaches.set(breachId, fullBreach); + this._transition(breach.registryEntryId, "breached"); + entry.breachId = breachId; + this._audit(breach.registryEntryId, "breach_filed", { + breachId, + filedBy: breach.filedBy, + deviationType: breach.deviation.type, + }); + + return fullBreach; + } + + // --------------------------------------------------------------------------- + // Query + // --------------------------------------------------------------------------- + + getEntry(registryEntryId: string): PoIRegistryEntry { + return this._get(registryEntryId); + } + + getBreach(breachId: string): PoIBreach { + const b = this._breaches.get(breachId); + if (!b) throw new Error(`Breach ${breachId} not found`); + return b; + } + + query(opts: RegistryQueryOptions = {}): PoIRegistryEntry[] { + const { + filerAddress, capability, status, since, until, + limit = 100, offset = 0, + } = opts; + + let candidates: PoIRegistryEntry[]; + + if (filerAddress) { + const ids = this._byFiler.get(filerAddress) ?? new Set(); + candidates = [...ids].map(id => this._entries.get(id)!).filter(Boolean); + } else { + candidates = [...this._entries.values()]; + } + + if (capability) candidates = candidates.filter(e => e.intent.capability === capability); + if (status) candidates = candidates.filter(e => e.status === status); + if (since) candidates = candidates.filter(e => e.receivedAt >= since); + if (until) candidates = candidates.filter(e => e.receivedAt <= until); + + candidates.sort((a, b) => b.receivedAt - a.receivedAt); + return candidates.slice(offset, offset + limit); + } + + // --------------------------------------------------------------------------- + // Audit Trail (court-admissible output) + // --------------------------------------------------------------------------- + + /** + * Return the full audit trail for an entry, including a SHA-256 hash + * of the entire trail for court-admissible verification. + */ + getAuditTrail(registryEntryId: string): { + registryEntryId: string; + entry: PoIRegistryEntry; + events: AuditTrailEntry[]; + trailHash: string; + exportedAt: number; + } { + const entry = this._get(registryEntryId); + const events = this._auditTrail.get(registryEntryId) ?? []; + const trailPayload = JSON.stringify({ registryEntryId, entry, events }); + const trailHash = createHash("sha256").update(trailPayload).digest("hex"); + + return { + registryEntryId, + entry, + events, + trailHash, + exportedAt: unixNow(), + }; + } + + stats(): { totalEntries: number; totalBreaches: number; byStatus: Record } { + const byStatus: Record = {}; + for (const entry of this._entries.values()) { + byStatus[entry.status] = (byStatus[entry.status] ?? 0) + 1; + } + return { totalEntries: this._entries.size, totalBreaches: this._breaches.size, byStatus }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private _get(id: string): PoIRegistryEntry { + const entry = this._entries.get(id); + if (!entry) throw new Error(`Registry entry ${id} not found`); + return entry; + } + + private _transition(id: string, status: RegistryStatus): void { + const entry = this._get(id); + const prev = entry.status; + entry.status = status; + entry.resolvedAt = unixNow(); + this._audit(id, "status_changed", { from: prev, to: status }); + } + + private _audit(id: string, event: AuditTrailEntry["event"], detail: Record): void { + if (!this._auditTrail.has(id)) this._auditTrail.set(id, []); + this._auditTrail.get(id)!.push({ timestamp: unixNow(), event, detail }); + } +} + +function unixNow(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/packages/intent-registry/src/types.ts b/packages/intent-registry/src/types.ts new file mode 100644 index 0000000..0f4b704 --- /dev/null +++ b/packages/intent-registry/src/types.ts @@ -0,0 +1,103 @@ +/** + * ZeroQuery Intent Registry — type definitions. + * Mirrors the JSON schemas in /schema/ as TypeScript interfaces. + */ + +export type Rail = "usdc-sol" | "rlusd-xrp" | "xrp" | "usdc-base"; +export type RegistryStatus = "active" | "fulfilled" | "breached" | "expired" | "withdrawn"; +export type BreachType = + | "capability_mismatch" + | "param_deviation" + | "bond_violation" + | "rail_switch" + | "undeclared_action" + | "timeout"; + +export type EvidenceType = + | "xrpl_tx" + | "api_log" + | "mcp_call" + | "on_chain_record" + | "signed_attestation"; + +export interface PoIIntent { + "@context": "https://zeroquery.dev/ns/poi/v1"; + "@type": "PoIIntent"; + capability: string; + params: Record; + maxBond: number; + rail: Rail; + id?: string; + ttlSeconds?: number; + nonce?: string; +} + +export interface XrplAnchor { + txHash: string; + ledgerIndex: number; + closeTime: number; + account: string; +} + +export interface PoIRegistryEntry { + "@context": "https://zeroquery.dev/ns/poi/v1"; + "@type": "PoIRegistryEntry"; + registryEntryId: string; + intentId: string; + filerAddress: string; + receivedAt: number; + intentHash: string; + xrplAnchor?: XrplAnchor; + status: RegistryStatus; + resolvedAt?: number; + breachId?: string; + intent: PoIIntent; +} + +export interface EvidenceItem { + type: EvidenceType; + ref: string; + description?: string; +} + +export interface DeviationRecord { + type: BreachType; + declared: unknown; + actual: unknown; + deviationMagnitude?: number; +} + +export interface Remedy { + type: "bond_slash" | "rlusd_penalty" | "service_suspension" | "arbitration"; + amountRLUSD?: number; + narrative?: string; +} + +export interface PoIBreach { + "@context": "https://zeroquery.dev/ns/poi/v1"; + "@type": "PoIBreach"; + intentId: string; + registryEntryId: string; + filedBy: string; + filedAt: number; + deviation: DeviationRecord; + evidence: EvidenceItem[]; + remedy?: Remedy; + signature?: string; +} + +export interface RegistryQueryOptions { + filerAddress?: string; + capability?: string; + status?: RegistryStatus; + since?: number; + until?: number; + limit?: number; + offset?: number; +} + +export interface AuditTrailEntry { + timestamp: number; + event: "registered" | "status_changed" | "breach_filed" | "anchor_set"; + detail: Record; +} diff --git a/packages/intent-registry/test/registry.test.js b/packages/intent-registry/test/registry.test.js new file mode 100644 index 0000000..8266783 --- /dev/null +++ b/packages/intent-registry/test/registry.test.js @@ -0,0 +1,116 @@ +/** + * Basic smoke tests for the ZeroQuery IntentRegistry. + * Run with: node --test test/registry.test.js + */ + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { IntentRegistry } from "../dist/index.js"; + +const FILER = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; + +const baseIntent = () => ({ + "@context": "https://zeroquery.dev/ns/poi/v1", + "@type": "PoIIntent", + capability: "travel.hotel.search", + params: { city: "NYC", maxPrice: 200 }, + maxBond: 1000, + rail: "rlusd-xrp", +}); + +describe("IntentRegistry", () => { + it("registers an intent and returns a registry entry", () => { + const reg = new IntentRegistry(); + const entry = reg.register(baseIntent(), FILER); + assert.equal(entry["@type"], "PoIRegistryEntry"); + assert.equal(entry.status, "active"); + assert.equal(entry.filerAddress, FILER); + assert.ok(entry.intentHash.length === 64, "SHA-256 hex hash is 64 chars"); + assert.ok(entry.registryEntryId, "has registryEntryId"); + }); + + it("retrieves entry by id", () => { + const reg = new IntentRegistry(); + const entry = reg.register(baseIntent(), FILER); + const fetched = reg.getEntry(entry.registryEntryId); + assert.equal(fetched.intentHash, entry.intentHash); + }); + + it("detects capability mismatch breach", () => { + const reg = new IntentRegistry(); + const entry = reg.register(baseIntent(), FILER); + const dev = reg.detectBreach(entry.registryEntryId, { + capability: "finance.stock.buy", + }); + assert.ok(dev, "breach detected"); + assert.equal(dev.type, "capability_mismatch"); + assert.equal(dev.declared, "travel.hotel.search"); + assert.equal(dev.actual, "finance.stock.buy"); + }); + + it("detects bond violation", () => { + const reg = new IntentRegistry(); + const entry = reg.register(baseIntent(), FILER); + const dev = reg.detectBreach(entry.registryEntryId, { bondUsed: 1500 }); + assert.ok(dev); + assert.equal(dev.type, "bond_violation"); + }); + + it("returns null when compliant", () => { + const reg = new IntentRegistry(); + const entry = reg.register(baseIntent(), FILER); + const dev = reg.detectBreach(entry.registryEntryId, { + capability: "travel.hotel.search", + bondUsed: 999, + railUsed: "rlusd-xrp", + params: { city: "NYC", maxPrice: 205 }, // within 10% + }); + assert.equal(dev, null); + }); + + it("files a formal breach and transitions status", () => { + const reg = new IntentRegistry(); + const entry = reg.register(baseIntent(), FILER); + const breach = reg.fileBreach({ + intentId: entry.intentId, + registryEntryId: entry.registryEntryId, + filedBy: "rPVMhWBsfF9iMXYj3aAzJVkPDTFNSyWdKy", + filedAt: Math.floor(Date.now() / 1000), + deviation: { type: "capability_mismatch", declared: "travel.hotel.search", actual: "finance.stock.buy" }, + evidence: [{ type: "api_log", ref: "https://example.com/log/abc123" }], + }); + assert.equal(breach["@type"], "PoIBreach"); + const updated = reg.getEntry(entry.registryEntryId); + assert.equal(updated.status, "breached"); + }); + + it("emits an audit trail with trailHash", () => { + const reg = new IntentRegistry(); + const entry = reg.register(baseIntent(), FILER); + reg.markFulfilled(entry.registryEntryId); + const trail = reg.getAuditTrail(entry.registryEntryId); + assert.ok(trail.events.length >= 2); // registered + status_changed + assert.ok(trail.trailHash.length === 64); + assert.equal(trail.entry.status, "fulfilled"); + }); + + it("queries by filer address", () => { + const reg = new IntentRegistry(); + reg.register(baseIntent(), FILER); + reg.register({ ...baseIntent(), capability: "finance.market.scan" }, FILER); + const results = reg.query({ filerAddress: FILER }); + assert.equal(results.length, 2); + }); + + it("filters query by status", () => { + const reg = new IntentRegistry(); + const e1 = reg.register(baseIntent(), FILER); + reg.register(baseIntent(), FILER); + reg.markFulfilled(e1.registryEntryId); + const active = reg.query({ status: "active" }); + const fulfilled = reg.query({ status: "fulfilled" }); + assert.equal(fulfilled.length, 1); + assert.ok(active.length >= 1); + }); +}); diff --git a/packages/intent-registry/tsconfig.json b/packages/intent-registry/tsconfig.json new file mode 100644 index 0000000..544367b --- /dev/null +++ b/packages/intent-registry/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts index 4fb8907..0c6885d 100644 --- a/packages/relay/src/relay.ts +++ b/packages/relay/src/relay.ts @@ -10,7 +10,7 @@ * this package has a single dependency — the protocol SDK — and no network or * proprietary code. */ -import { isExpired, isValidDid, calculateIntentRank, resolveDid, type GossipMessage, type LedgerStateReader } from "@zeroquery/sdk"; +import { isExpired, isValidDid, calculateIntentRank, resolveDid, PAYMENT_RAILS, type GossipMessage, type LedgerStateReader } from "@zeroquery/sdk"; export interface Peer { id: string; @@ -168,7 +168,10 @@ export class RelayNode { msg.bondAmount > 0 && Number.isInteger(msg.timestamp) && Number.isInteger(msg.ttl) && - msg.ttl > 0 + msg.ttl > 0 && + // Validate that paymentRail is one of the spec-defined rails (spec §3.6). + // An unrecognized rail value indicates a malformed or spoofed message. + (PAYMENT_RAILS as readonly string[]).includes(msg.paymentRail) ); } } diff --git a/packages/sdk/src/intent.ts b/packages/sdk/src/intent.ts index d78d05c..43a5775 100644 --- a/packages/sdk/src/intent.ts +++ b/packages/sdk/src/intent.ts @@ -46,6 +46,13 @@ export interface GossipMessage { export const INTENT_CONTEXT = "https://zeroquery.dev/ns/poi/v1"; +/** + * Maximum serialized byte size of an intent payload (spec §4.1 — the gossip + * wire message must stay compact to bound relay memory and network cost). + * Payloads exceeding this limit are rejected by `validateIntentPayload`. + */ +export const MAX_INTENT_PAYLOAD_BYTES = 65_536; // 64 KiB + /** * Deterministic JSON canonicalization (sorted keys, no insignificant * whitespace). Good enough for a stable hash without pulling in a full @@ -108,6 +115,17 @@ export function buildGossipMessage(args: BuildGossipArgs): GossipMessage { /** Returns a list of human-readable validation errors ([] === valid). */ export function validateIntentPayload(payload: IntentPayload): string[] { const errors: string[] = []; + + // Guard against DoS via oversized payloads before any field inspection. + // The limit (MAX_INTENT_PAYLOAD_BYTES) is chosen to keep relay memory + // bounded while accommodating realistic intent parameter objects. + const serialized = JSON.stringify(payload); + if (typeof serialized === "string" && Buffer.byteLength(serialized, "utf8") > MAX_INTENT_PAYLOAD_BYTES) { + errors.push(`payload exceeds maximum size of ${MAX_INTENT_PAYLOAD_BYTES} bytes`); + // Return early — further validation is meaningless for an oversize payload. + return errors; + } + if (payload?.["@context"] !== INTENT_CONTEXT) { errors.push(`@context must be "${INTENT_CONTEXT}"`); } diff --git a/packages/sdk/src/resolver.ts b/packages/sdk/src/resolver.ts index 3b0e29d..a881184 100644 --- a/packages/sdk/src/resolver.ts +++ b/packages/sdk/src/resolver.ts @@ -183,11 +183,16 @@ export class XahauJsonRpcReader implements LedgerStateReader { body: JSON.stringify(body), }); if (!res.ok) throw new Error(`xahau node ${res.status}`); - const json: any = await res.json(); - const node = json?.result?.node; - if (!node || json?.result?.error) return null; - const dataHex: string | undefined = node.HookStateData; - if (!dataHex) return null; + const json: unknown = await res.json(); + if (typeof json !== "object" || json === null) return null; + const result = (json as Record)["result"]; + if (typeof result !== "object" || result === null) return null; + const resultObj = result as Record; + if (resultObj["error"]) return null; + const node = resultObj["node"]; + if (typeof node !== "object" || node === null) return null; + const dataHex = (node as Record)["HookStateData"]; + if (typeof dataHex !== "string") return null; return new Uint8Array(Buffer.from(dataHex, "hex")); } } diff --git a/packages/sdk/src/verifier.ts b/packages/sdk/src/verifier.ts index 624eb96..219d924 100644 --- a/packages/sdk/src/verifier.ts +++ b/packages/sdk/src/verifier.ts @@ -1,19 +1,44 @@ -import { createHash } from "node:crypto"; -// In a full solana/web3.js integration, we'd use PublicKey.findProgramAddressSync. -// For the SDK which aims to be light and unopinionated (as seen in dust.ts/did.ts), -// we provide the deterministic derivation logic. +/** + * ZK verifier helpers for the poi-verifier Solana program. (spec §3.5) + * + * The `poi-verifier` program owns a PDA (`verifier_authority`) that acts as the + * `verifier` signer for `poi-escrow` bonds. Off-chain provers must know this + * PDA's address before constructing the CPI transaction. + * + * NOTE: `getVerifierPda()` below is a compile-time placeholder. In production, + * use `@solana/web3.js` `PublicKey.findProgramAddressSync` with the seed + * `[b"verifier_authority"]` against `POI_VERIFIER_PROGRAM_ID`. + */ +/** On-chain program ID of the poi-verifier Solana program (Phase 2 scaffold). */ export const POI_VERIFIER_PROGRAM_ID = "Verif1er11111111111111111111111111111111111"; /** - * Returns the Verifier PDA string and bump. - * This PDA is used as the `verifier` in `poi-escrow` bonds for ZK attestation. + * Returns the deterministic Verifier PDA address and bump seed. + * + * The PDA is derived from the seed `["verifier_authority"]` under + * `POI_VERIFIER_PROGRAM_ID`. It is stored as the `verifier` field when + * opening a bond so the `poi-verifier` program can sign CPI calls to + * `poi-escrow::fulfill` / `poi-escrow::slash` without exposing a private key. + * + * @returns An object with `pda` (base58 address) and `bump` (canonical bump). + * + * @remarks + * This is a SDK-layer placeholder that returns a deterministic mock value. + * In a live integration, call: + * ```ts + * import { PublicKey } from "@solana/web3.js"; + * const [pda, bump] = PublicKey.findProgramAddressSync( + * [Buffer.from("verifier_authority")], + * new PublicKey(POI_VERIFIER_PROGRAM_ID), + * ); + * ``` */ export function getVerifierPda(): { pda: string; bump: number } { - // In a real implementation this would use PublicKey.findProgramAddressSync - // For the SDK placeholder we return a mock structure. + // Placeholder — replace with PublicKey.findProgramAddressSync in a full + // @solana/web3.js integration (see JSDoc above). return { pda: "ZKPda11111111111111111111111111111111111111", - bump: 255 + bump: 255, }; } diff --git a/packages/zk-circuits/Cargo.toml b/packages/zk-circuits/Cargo.toml new file mode 100644 index 0000000..f91f3e8 --- /dev/null +++ b/packages/zk-circuits/Cargo.toml @@ -0,0 +1,7 @@ +# Separate Cargo workspace for ZK circuits. +# sp1-sdk requires zeroize ^1.7 while anchor-lang requires zeroize <1.4. +# These two ecosystems are incompatible in a single workspace, so the ZK +# circuits live in their own workspace and are checked by a separate CI job. +[workspace] +members = ["program", "script"] +resolver = "2" diff --git a/packages/zk-circuits/program/Cargo.toml b/packages/zk-circuits/program/Cargo.toml index 31635c4..91c9def 100644 --- a/packages/zk-circuits/program/Cargo.toml +++ b/packages/zk-circuits/program/Cargo.toml @@ -4,7 +4,7 @@ name = "poi-circuit" edition = "2021" [dependencies] -sp1-zkvm = "3.0.0" +sp1-zkvm = "3.4.0" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", default-features = false, features = ["alloc"] } ed25519-dalek = { version = "2.1.0", default-features = false } diff --git a/packages/zk-circuits/script/Cargo.toml b/packages/zk-circuits/script/Cargo.toml index 1d2711a..333517a 100644 --- a/packages/zk-circuits/script/Cargo.toml +++ b/packages/zk-circuits/script/Cargo.toml @@ -4,7 +4,7 @@ name = "poi-prover" edition = "2021" [dependencies] -sp1-sdk = "3.0.0" +sp1-sdk = "3.4.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1.37.0", features = ["full", "rt-multi-thread", "macros"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 591405d..ddc1aa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,19 @@ importers: specifier: ^5.4.5 version: 5.9.3 + packages/intent-registry: + dependencies: + '@zeroquery/sdk': + specifier: workspace:* + version: link:../sdk + devDependencies: + '@types/node': + specifier: ^20.12.0 + version: 20.19.43 + typescript: + specifier: ^5.4.5 + version: 5.9.3 + packages/relay: dependencies: '@zeroquery/sdk': diff --git a/programs/poi-escrow/src/lib.rs b/programs/poi-escrow/src/lib.rs index 24d6c23..4ba3687 100644 --- a/programs/poi-escrow/src/lib.rs +++ b/programs/poi-escrow/src/lib.rs @@ -14,11 +14,23 @@ //! the verifier is an oracle/attestation key; in Phase 2 it is replaced by //! the ZK provenance verifier program (spec §3.5) via CPI — still no human. //! +//! COIN ISOLATION (spec §3.6): +//! The escrow only accepts the single `mint` the broadcaster passes at bond +//! creation. The vault is seeded with `intent_hash` AND the mint address so +//! it is impossible for the vault to receive tokens of a different mint than +//! the one locked at open time. All resolution paths (`fulfill`, `slash`, +//! `expire`) validate `bond.mint == mint` via the `has_one` constraint before +//! any transfer executes. The program therefore NEVER holds SPL tokens from +//! more than one mint per bond, and mixing mints across bonds is structurally +//! impossible. +//! //! Outcomes: //! fulfill -> vault → responder (verifier attests a valid provenance proof) //! expire -> vault → broadcaster (anyone may crank once `expiry` passes) //! slash -> vault → slash_sink (verifier attests a false fulfillment) +#![deny(unused_must_use)] + use anchor_lang::prelude::*; use anchor_spl::token::{self, CloseAccount, Mint, Token, TokenAccount, Transfer}; diff --git a/programs/poi-gossip/src/lib.rs b/programs/poi-gossip/src/lib.rs index 487f055..2f1da63 100644 --- a/programs/poi-gossip/src/lib.rs +++ b/programs/poi-gossip/src/lib.rs @@ -16,6 +16,8 @@ //! NOTE: build with the Anchor/Solana SBF toolchain (`anchor build`). The pinned //! versions are in Cargo.toml. +#![deny(unused_must_use)] + use anchor_lang::prelude::*; use anchor_lang::system_program; @@ -49,7 +51,12 @@ pub mod poi_gossip { Ok(()) } - /// Admin-only: update the treasury or fee. + /// Admin-only: update the protocol treasury address or the broadcast fee. + /// + /// Either field is optional — pass `None` to leave it unchanged. + /// Only the `admin` key recorded in `Config` at `initialize` time may call + /// this instruction (enforced by the `has_one = admin` constraint on + /// [`SetParams`]). pub fn set_params( ctx: Context, new_treasury: Option, diff --git a/programs/poi-verifier/src/lib.rs b/programs/poi-verifier/src/lib.rs index 4598f1d..d9fd4a1 100644 --- a/programs/poi-verifier/src/lib.rs +++ b/programs/poi-verifier/src/lib.rs @@ -1,3 +1,5 @@ +#![deny(unused_must_use)] + use anchor_lang::prelude::*; use poi_escrow::cpi::accounts::Resolve; use poi_escrow::program::PoiEscrow; @@ -10,7 +12,12 @@ declare_id!("Verif1er11111111111111111111111111111111111"); pub mod poi_verifier { use super::*; - /// Initialize the global verifier configuration with the ZK prover Image ID. + /// One-time initialization: record the ZK prover Image ID and the admin key. + /// + /// `image_id` is the 32-byte SP1 guest program image ID committed at compile + /// time. `submit_proof` will verify incoming proofs against this value in + /// Phase 2 (currently a scaffold stub). Only `admin` can update this via a + /// future `set_image_id` instruction. pub fn initialize(ctx: Context, image_id: [u8; 32]) -> Result<()> { let config = &mut ctx.accounts.config; config.admin = ctx.accounts.admin.key(); @@ -18,18 +25,33 @@ pub mod poi_verifier { Ok(()) } - /// Submit a ZK proof to be verified. On success, it invokes `poi-escrow` to fulfill or slash. + /// Submit a ZK proof for on-chain verification; on success CPI-calls `poi-escrow` + /// to fulfill or slash the associated bond. + /// + /// # Arguments + /// * `intent_hash` — 32-byte SHA-256 hash of the canonical intent payload. + /// * `proof_hash` — 32-byte commitment to the ZK proof (written into the bond record). + /// * `outcome` — 1 = fulfill (release bond to responder), 3 = slash (penalise). + /// * `proof_data` — Serialised SP1 Groth16/Plonk proof bytes. + /// + /// # Phase 2 note + /// The real implementation must invoke the SP1 Groth16/Plonk on-chain verifier + /// against `config.image_id` instead of the non-empty-bytes stub below. + /// Until that CPI is wired, any non-empty `proof_data` is accepted — this is + /// intentional scaffolding and MUST NOT be deployed to mainnet as-is. pub fn submit_proof( ctx: Context, intent_hash: [u8; 32], proof_hash: [u8; 32], outcome: u8, // 1 = fulfill, 3 = slash - _proof_data: Vec, // The actual SP1 SNARK proof data + proof_data: Vec, // The actual SP1 SNARK proof data ) -> Result<()> { // Step 1: Verify the ZK proof against the stored `config.image_id`. - // (In a full SP1 integration, we would invoke the SP1 Groth16/Plonk verifier program here). - // For Phase 2 scaffolding, we mock the success if proof_data is non-empty. - require!(!_proof_data.is_empty(), VerifierError::InvalidProof); + // + // PHASE 2 SCAFFOLD: a non-empty byte string stands in for a real SP1 + // Groth16/Plonk verification CPI against `ctx.accounts.config.image_id`. + // Replace this block with the actual verifier CPI before mainnet deployment. + require!(!proof_data.is_empty(), VerifierError::InvalidProof); // Step 2: Sign the CPI using the verifier PDA. let bump = ctx.bumps.verifier_authority; diff --git a/schema/breach.schema.json b/schema/breach.schema.json new file mode 100644 index 0000000..d0ee959 --- /dev/null +++ b/schema/breach.schema.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://zeroquery.dev/ns/poi/v1/breach.schema.json", + "title": "PoIBreach", + "description": "Proof-of-Breach declaration (spec §8). Filed when an agent's actual action deviates materially from a registered PoIIntent. Court-admissible when anchored to a registryEntryId with a valid XRPL timestamp.", + "type": "object", + "required": ["@context", "@type", "intentId", "registryEntryId", "filedBy", "filedAt", "deviation", "evidence"], + "additionalProperties": false, + "properties": { + "@context": { "const": "https://zeroquery.dev/ns/poi/v1" }, + "@type": { "const": "PoIBreach" }, + "intentId": { + "type": "string", + "description": "The id of the original PoIIntent that was breached.", + "format": "uri", + "minLength": 1 + }, + "registryEntryId": { + "type": "string", + "description": "The registry entry id from the intent registry (contains XRPL anchor txHash).", + "minLength": 1 + }, + "filedBy": { + "type": "string", + "description": "XRPL r-address or DID of the party filing the breach claim.", + "minLength": 1 + }, + "filedAt": { + "type": "integer", + "description": "Unix timestamp (seconds) when this breach was filed.", + "minimum": 0 + }, + "deviation": { + "type": "object", + "description": "Structured comparison of declared vs actual values.", + "required": ["type", "declared", "actual"], + "properties": { + "type": { + "type": "string", + "enum": ["capability_mismatch", "param_deviation", "bond_violation", "rail_switch", "undeclared_action", "timeout"], + "description": "Category of breach." + }, + "declared": { + "description": "The declared value from the original PoIIntent." + }, + "actual": { + "description": "The observed value from the on-chain or API record." + }, + "deviationMagnitude": { + "type": "number", + "description": "For numeric deviations: |actual - declared| / declared (0–1 = percentage deviation)." + } + }, + "additionalProperties": false + }, + "evidence": { + "type": "array", + "description": "Chain of evidence supporting the breach claim.", + "minItems": 1, + "items": { + "type": "object", + "required": ["type", "ref"], + "properties": { + "type": { + "type": "string", + "enum": ["xrpl_tx", "api_log", "mcp_call", "on_chain_record", "signed_attestation"] + }, + "ref": { + "type": "string", + "description": "XRPL tx hash, URL, or content-addressed hash of the evidence artifact." + }, + "description": { "type": "string" } + }, + "additionalProperties": false + } + }, + "remedy": { + "type": "object", + "description": "Optional requested remedy.", + "properties": { + "type": { "type": "string", "enum": ["bond_slash", "rlusd_penalty", "service_suspension", "arbitration"] }, + "amountRLUSD": { "type": "number", "minimum": 0 }, + "narrative": { "type": "string", "maxLength": 1024 } + }, + "additionalProperties": false + }, + "signature": { + "type": "string", + "description": "Ed25519 signature over the canonical JSON of this document (excluding this field), base64url-encoded." + } + } +} diff --git a/schema/registry-entry.schema.json b/schema/registry-entry.schema.json new file mode 100644 index 0000000..46bb09e --- /dev/null +++ b/schema/registry-entry.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://zeroquery.dev/ns/poi/v1/registry-entry.schema.json", + "title": "PoIRegistryEntry", + "description": "An entry in the ZeroQuery intent registry. Wraps a PoIIntent with anchoring metadata: server timestamp, XRPL memo hash, and filer identity. Forms the immutable audit record.", + "type": "object", + "required": ["@context", "@type", "registryEntryId", "intentId", "filerAddress", "receivedAt", "intentHash", "intent"], + "additionalProperties": false, + "properties": { + "@context": { "const": "https://zeroquery.dev/ns/poi/v1" }, + "@type": { "const": "PoIRegistryEntry" }, + "registryEntryId": { + "type": "string", + "description": "UUID v4 assigned by the registry at ingestion time.", + "format": "uuid" + }, + "intentId": { + "type": "string", + "description": "Mirrors the id field from the wrapped PoIIntent.", + "format": "uri" + }, + "filerAddress": { + "type": "string", + "description": "XRPL r-address that submitted this intent. Used for breach lookup and credit bureau correlation." + }, + "receivedAt": { + "type": "integer", + "description": "Unix timestamp (seconds) when the registry received and stored this entry.", + "minimum": 0 + }, + "intentHash": { + "type": "string", + "description": "SHA-256 hex digest of the canonical JSON of the PoIIntent (before registry wrapping). Immutable content fingerprint." + }, + "xrplAnchor": { + "type": "object", + "description": "Optional: XRPL transaction that carries this intent hash as a Memo field. Provides court-admissible on-chain timestamp.", + "properties": { + "txHash": { "type": "string", "description": "XRPL transaction hash (64 hex chars)." }, + "ledgerIndex": { "type": "integer" }, + "closeTime": { "type": "integer", "description": "Ledger close time (unix seconds)." }, + "account": { "type": "string", "description": "Submitting XRPL account." } + }, + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": ["active", "fulfilled", "breached", "expired", "withdrawn"], + "description": "Lifecycle status of this intent. Updated by breach detection or fulfilment confirmation.", + "default": "active" + }, + "resolvedAt": { + "type": "integer", + "description": "Unix timestamp when status moved from active to a terminal state." + }, + "breachId": { + "type": "string", + "description": "registryEntryId of the PoIBreach record, if status == breached." + }, + "intent": { + "$ref": "https://zeroquery.dev/ns/poi/v1/intent.schema.json", + "description": "The full PoIIntent payload, stored verbatim." + } + } +}