diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4e7bc8..982830e 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,41 @@ 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 + - run: cargo test -p poi-subscription + + 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 + + circuits: + name: ZK provenance reference (JS) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + - run: cd circuits && npm install + - run: cd circuits && npm test diff --git a/.gitignore b/.gitignore index 9a55422..4b63d22 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,15 @@ test-ledger/ hooks/**/*.o hooks/**/*.wasm +# ZK circuit build artifacts (regenerated by circuits/build.sh) +circuits/*.r1cs +circuits/*.sym +circuits/*.ptau +circuits/*.zkey +circuits/*_js/ +circuits/verification_key.json +circuits/verifier.sol + # Env / secrets — NEVER commit keys or seeds (zero-custody: no treasury keys in repo) .env .env.* diff --git a/Anchor.toml b/Anchor.toml index b61ef11..47dd801 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -8,11 +8,13 @@ skip-lint = false [programs.localnet] poi_gossip = "5ycmzEXUYMx4uRVu4hLqqNXRMzWhUu7KvMVeDfECE9o1" poi_escrow = "G41hFoSfYJ6ETtvxVayZtFx4oUVwWY7ctsgUB1BQBtPH" +poi_subscription = "9MeEYCFExtHAFiXa4ZFmW4nh34n3mk2hyqm91jDwSbEE" poi_verifier = "Verif1er11111111111111111111111111111111111" [programs.devnet] poi_gossip = "5ycmzEXUYMx4uRVu4hLqqNXRMzWhUu7KvMVeDfECE9o1" poi_escrow = "G41hFoSfYJ6ETtvxVayZtFx4oUVwWY7ctsgUB1BQBtPH" +poi_subscription = "9MeEYCFExtHAFiXa4ZFmW4nh34n3mk2hyqm91jDwSbEE" poi_verifier = "Verif1er11111111111111111111111111111111111" [registry] diff --git a/Cargo.lock b/Cargo.lock index 78114df..42c55cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1551,6 +1551,13 @@ dependencies = [ "anchor-lang", ] +[[package]] +name = "poi-subscription" +version = "0.1.0" +dependencies = [ + "anchor-lang", +] + [[package]] name = "poi-verifier" version = "0.1.0" diff --git a/README.md b/README.md index a5f54f9..65e5bf1 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ treasury is SOL SaaS revenue (§3.1). zeroquery-protocol/ ├── programs/ │ ├── poi-gossip/ # Anchor/Rust — L1 intent broadcast + Intent Dust event -│ └── poi-escrow/ # Anchor/Rust — L3 non-custodial USDC intent bonds (x402) +│ ├── poi-escrow/ # Anchor/Rust — L3 non-custodial USDC intent bonds (x402) +│ └── poi-subscription/ # Anchor/Rust — SOL SaaS tier management (Scout/Runner/Relay/Builder) ├── hooks/xah-did/ # Xahau Hook (C → wasm32) — DID resolution + soulbound reputation ├── packages/ │ ├── sdk/ # @zeroquery/sdk — DID, intent gossip, dust, resolver @@ -59,18 +60,22 @@ zeroquery-protocol/ | Deliverable | State | Verified in-repo | |-------------|-------|------------------| -| `@zeroquery/sdk` — DID resolution, intent gossip, Intent Dust | ✅ working | 27 tests passing | +| `@zeroquery/sdk` — DID, intent gossip, Intent Dust, IntentRank | ✅ working | 35 tests passing | | `@zeroquery/relay` — open-source gossip node | ✅ working | 6 tests passing | | Intent schema + canonical hashing + gossip message | ✅ working | covered by SDK tests | | `xah-did` Hook (DID → soulbound reputation) | ✅ compiles to wasm32 | `pnpm hook:build` | | `poi-gossip` Anchor program (L1 broadcast) | ✅ compiles | `cargo check --workspace` | | `poi-escrow` Anchor program (L3 x402 USDC bonds) | ✅ compiles | `cargo check --workspace` | +| `poi-subscription` Anchor program (SOL SaaS tiers) | ✅ compiles + unit-tested | `cargo test -p poi-subscription` | +| IntentRank matching (L2, Phase 2) | ✅ working | in the SDK suite | +| ZK provenance scheme (Phase 2) | ✅ JS-verified | `cd circuits && npm test` | +| ZK Groth16 circuit + setup | ⏳ needs circom/snarkjs | `circuits/build.sh` | | End-to-end Phase 1 flow | ✅ runs | `pnpm example` | -| ZK provenance circuits | ⬜ Phase 2 | — | | Live Xahau-testnet / devnet deploy | ⬜ needs creds | runbook in `docs/DEPLOY.md` | -Full mapping of constraints → code in [`docs/COMPLIANCE.md`](docs/COMPLIANCE.md); -scope detail in [`docs/PHASE1.md`](docs/PHASE1.md). +Constraints → code in [`docs/COMPLIANCE.md`](docs/COMPLIANCE.md); scope in +[`docs/PHASE1.md`](docs/PHASE1.md) and [`docs/PHASE2.md`](docs/PHASE2.md); +self-review in [`docs/AUDIT.md`](docs/AUDIT.md). --- diff --git a/circuits/README.md b/circuits/README.md new file mode 100644 index 0000000..2140bc9 --- /dev/null +++ b/circuits/README.md @@ -0,0 +1,53 @@ +# @zeroquery/circuits — ZK provenance + +Zero-Knowledge attestation that a responder actually obtained data at a given +time, bound to its identity and to a specific intent — without revealing the raw +data or its private key. (spec §3.5 ZK Attestation Mandate, §4.5 ZK Provenance.) + +## The scheme + +Witness (private): `apiResponseHash`, `salt`, `privateKey` +Public inputs: `timestamp`, `intentHash` +Public outputs: `commitment`, `nullifier` + +``` +commitment = Poseidon(apiResponseHash, timestamp, salt) +nullifier = Poseidon(privateKey, intentHash) +``` + +- **commitment** — binds hidden data to a public timestamp. `salt` makes it + hiding; opening it later proves "I held this exact response at this time". +- **nullifier** — binds the proof to the responder's secret key and this intent. + A verifier that records spent nullifiers gets replay protection, and a + false-attestation **slash** (spec §3.5) targets the nullifier without ever + learning the key. + +Poseidon (not SHA-256) is used because it is SNARK-friendly. + +## What's verifiable here vs. what needs the toolchain + +- ✅ **`reference.mjs` + `reference.test.mjs`** — a pure-JS implementation using + the *same* Poseidon hash the circuit constrains. Run `npm test` (or + `pnpm test`) to verify determinism, hiding, identity/intent binding, and + replay detectability. This is the off-chain attestation an agent posts with + its proof; its public signals match the circuit's outputs exactly. +- ⏳ **`provenance.circom` + `build.sh`** — the Groth16 circuit and trusted + setup. Compiling/proving needs `circom` (Rust) + `snarkjs`, which are not + bundled. `build.sh` produces `r1cs`, wasm witness gen, `*_final.zkey`, + `verification_key.json`, and `verifier.sol`. + +## Build (needs circom + snarkjs) + +```bash +npm install # circomlib (.circom sources) + circomlibjs (JS reference) +npm test # verify the scheme in pure JS — no toolchain needed +bash build.sh # compile + Groth16 setup -> proving/verification keys +``` + +## On-chain verification + +The exported Groth16 verifier (`verifier.sol`) is the reference for the +on-chain check. In the protocol, the escrow program's `verifier` authority +(see `programs/poi-escrow`) is replaced by a Groth16 verifier that gates +`fulfill`/`slash` on a valid provenance proof + an unspent nullifier — closing +the loop with no human key in the path. diff --git a/circuits/build.sh b/circuits/build.sh new file mode 100644 index 0000000..795e2bf --- /dev/null +++ b/circuits/build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Compile provenance.circom and run the Groth16 trusted setup. +# +# Requires (not bundled — install separately): +# - circom >= 2.1 https://docs.circom.io/getting-started/installation/ +# - snarkjs npm i -g snarkjs +# - circomlib provided as a devDependency (the .circom sources) +set -euo pipefail +cd "$(dirname "$0")" + +CIRCUIT=provenance +PTAU=pot12_final.ptau + +command -v circom >/dev/null || { echo "circom not installed: https://docs.circom.io/getting-started/installation/"; exit 1; } +command -v snarkjs >/dev/null || { echo "snarkjs not installed: npm i -g snarkjs"; exit 1; } +[ -d node_modules/circomlib ] || { echo "run 'pnpm install' (or npm install) here first for circomlib"; exit 1; } + +# 1. Compile -> r1cs + wasm witness generator (circomlib on the include path). +circom "$CIRCUIT.circom" --r1cs --wasm --sym -l node_modules + +# 2. Powers of Tau (universal phase-1). 2^12 constraints is ample for this circuit. +snarkjs powersoftau new bn128 12 pot12_0000.ptau -v +snarkjs powersoftau contribute pot12_0000.ptau pot12_0001.ptau --name="zeroquery" -e="$(head -c 32 /dev/urandom | xxd -p)" +snarkjs powersoftau prepare phase2 pot12_0001.ptau "$PTAU" -v + +# 3. Groth16 setup -> proving + verification keys. +snarkjs groth16 setup "$CIRCUIT.r1cs" "$PTAU" "${CIRCUIT}_0000.zkey" +snarkjs zkey contribute "${CIRCUIT}_0000.zkey" "${CIRCUIT}_final.zkey" --name="zeroquery" -e="$(head -c 32 /dev/urandom | xxd -p)" +snarkjs zkey export verificationkey "${CIRCUIT}_final.zkey" verification_key.json + +# 4. Export an on-chain verifier (Groth16). The Solidity verifier doubles as the +# reference for the Solana verifier program integration. +snarkjs zkey export solidityverifier "${CIRCUIT}_final.zkey" verifier.sol + +echo "OK -> ${CIRCUIT}.r1cs, ${CIRCUIT}_js/, ${CIRCUIT}_final.zkey, verification_key.json, verifier.sol" diff --git a/circuits/package.json b/circuits/package.json new file mode 100644 index 0000000..f730cdc --- /dev/null +++ b/circuits/package.json @@ -0,0 +1,16 @@ +{ + "name": "@zeroquery/circuits", + "version": "0.1.0", + "private": true, + "description": "ZeroQuery ZK provenance circuit (Groth16) + JS reference for the commitment/nullifier scheme.", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "test": "node --test reference.test.mjs", + "build": "bash build.sh" + }, + "devDependencies": { + "circomlib": "^2.0.5", + "circomlibjs": "^0.1.7" + } +} diff --git a/circuits/provenance.circom b/circuits/provenance.circom new file mode 100644 index 0000000..1a261a9 --- /dev/null +++ b/circuits/provenance.circom @@ -0,0 +1,57 @@ +pragma circom 2.1.6; + +include "circomlib/circuits/poseidon.circom"; + +/* + * Provenance — Zero-Knowledge attestation of intent fulfillment. (spec §3.5, §4.5) + * + * A responder proves it actually obtained some data (e.g. an API response) at a + * given time, and that the attestation is bound to its identity and to this + * specific intent — WITHOUT revealing the raw response or its private key. + * + * Private (witness): apiResponseHash, salt, privateKey + * Public (inputs): timestamp, intentHash + * Public (outputs): commitment, nullifier + * + * Constraints: + * commitment = Poseidon(apiResponseHash, timestamp, salt) + * binds the (hidden) data to a (public) timestamp; `salt` hides it so the + * commitment reveals nothing about the response — opening it later proves + * "I had this exact data at this time". + * + * nullifier = Poseidon(privateKey, intentHash) + * binds the proof to the responder's secret key AND this intent. The same + * key cannot produce two distinct nullifiers for one intent, so a verifier + * that records spent nullifiers gets replay protection; a false-attestation + * slash (spec §3.5) targets the nullifier without ever learning the key. + * + * Poseidon is used (not SHA-256) because it is SNARK-friendly — cheap inside the + * field arithmetic of a zk circuit. + */ +template Provenance() { + // --- private witness --- + signal input apiResponseHash; + signal input salt; + signal input privateKey; + + // --- public inputs --- + signal input timestamp; + signal input intentHash; + + // --- public outputs --- + signal output commitment; + signal output nullifier; + + component c = Poseidon(3); + c.inputs[0] <== apiResponseHash; + c.inputs[1] <== timestamp; + c.inputs[2] <== salt; + commitment <== c.out; + + component n = Poseidon(2); + n.inputs[0] <== privateKey; + n.inputs[1] <== intentHash; + nullifier <== n.out; +} + +component main { public [ timestamp, intentHash ] } = Provenance(); diff --git a/circuits/reference.mjs b/circuits/reference.mjs new file mode 100644 index 0000000..d0ac6ca --- /dev/null +++ b/circuits/reference.mjs @@ -0,0 +1,50 @@ +/** + * Pure-JS reference for the provenance scheme in provenance.circom. + * + * Computes the SAME Poseidon commitments/nullifiers the circuit constrains, so + * an off-chain attestation produced here matches the circuit's public outputs + * bit-for-bit. This lets the scheme be tested and used without the circom/snarkjs + * proving toolchain installed (the on-chain SNARK proof is generated via + * build.sh once that toolchain is available). + */ +import { buildPoseidon } from "circomlibjs"; +import { createHash } from "node:crypto"; + +// BN254 scalar field prime (the field circom/snarkjs Groth16 operate in). +export const FIELD_PRIME = + 21888242871839275222246405745257275088548364400416034343698204186575808495617n; + +let _poseidon = null; +async function poseidon() { + if (!_poseidon) _poseidon = await buildPoseidon(); + return _poseidon; +} + +/** Reduce arbitrary bytes to a field element (e.g. hash a raw API response). */ +export function toField(bytes) { + const h = createHash("sha256").update(bytes).digest("hex"); + return BigInt("0x" + h) % FIELD_PRIME; +} + +/** commitment = Poseidon(apiResponseHash, timestamp, salt) — returns a decimal string. */ +export async function commitment(apiResponseHash, timestamp, salt) { + const p = await poseidon(); + return p.F.toString(p([BigInt(apiResponseHash), BigInt(timestamp), BigInt(salt)])); +} + +/** nullifier = Poseidon(privateKey, intentHash) — returns a decimal string. */ +export async function nullifier(privateKey, intentHash) { + const p = await poseidon(); + return p.F.toString(p([BigInt(privateKey), BigInt(intentHash)])); +} + +/** Build the full public attestation an agent posts alongside its ZK proof. */ +export async function attest({ apiResponse, timestamp, salt, privateKey, intentHash }) { + const apiResponseHash = toField(Buffer.from(apiResponse)); + return { + timestamp: BigInt(timestamp).toString(), + intentHash: BigInt(intentHash).toString(), + commitment: await commitment(apiResponseHash, timestamp, salt), + nullifier: await nullifier(privateKey, intentHash), + }; +} diff --git a/circuits/reference.test.mjs b/circuits/reference.test.mjs new file mode 100644 index 0000000..45d9351 --- /dev/null +++ b/circuits/reference.test.mjs @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { commitment, nullifier, toField, attest, FIELD_PRIME } from "./reference.mjs"; + +const TS = 1_700_000_000; +const INTENT = toField(Buffer.from("intent-hash-bytes")); +const KEY = 12345678901234567890n; + +test("toField reduces into the BN254 field", () => { + const f = toField(Buffer.from("some api response body")); + assert.ok(f >= 0n && f < FIELD_PRIME); +}); + +test("commitment is deterministic", async () => { + const a = await commitment(111n, TS, 999n); + const b = await commitment(111n, TS, 999n); + assert.equal(a, b); +}); + +test("commitment hides the response: different salt -> different commitment", async () => { + const a = await commitment(111n, TS, 1n); + const b = await commitment(111n, TS, 2n); + assert.notEqual(a, b); +}); + +test("commitment binds the timestamp", async () => { + const a = await commitment(111n, TS, 5n); + const b = await commitment(111n, TS + 1, 5n); + assert.notEqual(a, b); +}); + +test("nullifier is deterministic per (key,intent) -> replay is detectable", async () => { + const a = await nullifier(KEY, INTENT); + const b = await nullifier(KEY, INTENT); + assert.equal(a, b); // a verifier recording spent nullifiers blocks the reuse +}); + +test("nullifier binds identity and intent", async () => { + const base = await nullifier(KEY, INTENT); + assert.notEqual(base, await nullifier(KEY + 1n, INTENT)); // different key + assert.notEqual(base, await nullifier(KEY, INTENT + 1n)); // different intent +}); + +test("attest() bundles the public signals an agent posts with its proof", async () => { + const att = await attest({ + apiResponse: '{"price": 219.00}', + timestamp: TS, + salt: 424242n, + privateKey: KEY, + intentHash: INTENT, + }); + assert.equal(att.timestamp, String(TS)); + assert.match(att.commitment, /^[0-9]+$/); + assert.match(att.nullifier, /^[0-9]+$/); + // reproducible + const again = await attest({ apiResponse: '{"price": 219.00}', timestamp: TS, salt: 424242n, privateKey: KEY, intentHash: INTENT }); + assert.deepEqual(att, again); +}); diff --git a/docs/PHASE2.md b/docs/PHASE2.md new file mode 100644 index 0000000..1f99c05 --- /dev/null +++ b/docs/PHASE2.md @@ -0,0 +1,61 @@ +# Phase 2 — Matching + +Scope (spec §9 Phase 2): IntentRank algorithm · ZK proof circuit · reputation +hook on XAH · Intent Dust generators. + +## 1. IntentRank — `packages/sdk/src/intentrank.ts` + +Status: **working & tested** (7 tests). Reputation-weighted matching: + +``` +IntentRank(S) = Σ (fulfillment_value · proof_quality · recency_decay) + ---------------------------------------------------------- + 1 + Σ (failure_severity · recency_decay) +``` + +- `intentRank(history, opts)` — score one service; `rankServices(candidates)` — + rank many, descending, with a deterministic DID tie-break so independent + agents converge on the same winner. +- Recency decay shares the soulbound-reputation half-life model (spec §3.2). +- `proof_quality` is the hook where Phase 2's ZK provenance feeds in: a + ZK-verified fulfillment carries quality 1, an unproven one trends to 0. + +## 2. ZK provenance circuit — `circuits/` + +Status: **scheme verified in JS; circuit + setup pending the proving toolchain.** + +- `provenance.circom` — Groth16 circuit proving knowledge of + `(apiResponseHash, salt, privateKey)` such that + `commitment = Poseidon(apiResponseHash, timestamp, salt)` and + `nullifier = Poseidon(privateKey, intentHash)` — without revealing the + response or the key. +- `reference.mjs` / `reference.test.mjs` — a pure-JS implementation using the + same Poseidon hash, **7 tests passing** (determinism, hiding, identity/intent + binding, replay detectability). The off-chain attestation matches the + circuit's public outputs exactly. +- `build.sh` — compile + trusted setup → proving/verification keys + an on-chain + verifier. Requires `circom` + `snarkjs` (not bundled). + +**Outstanding:** run `build.sh` on a machine with the toolchain; wire the +exported Groth16 verifier into `poi-escrow` as the `verifier` authority so +`fulfill`/`slash` gate on a valid proof + unspent nullifier (no human key). + +## 3. Reputation hook on XAH + +Delivered in Phase 1 (`hooks/xah-did`) — the soulbound reputation write side. +Phase 2 adds the link: a successful ZK-verified fulfillment drives the `F` +(fulfilled) reputation event; a slash drives `X`. + +## 4. Intent Dust generators + +The encode/decode primitives shipped in Phase 1 (`packages/sdk/src/dust.ts`). +Phase 2 turns them into active generators (HTTP middleware, DNS publisher, +GitHub trailer injector) — a remaining build item. + +## Verification summary + +| Component | Build | Test | +|-----------|-------|------| +| IntentRank | `pnpm --filter @zeroquery/sdk build` | 7 tests (in the 35-test SDK suite) | +| ZK reference | `cd circuits && npm install` | `npm test` — 7 tests | +| ZK circuit | `cd circuits && bash build.sh` | needs circom + snarkjs | diff --git a/packages/ghost-layer/src/index.ts b/packages/ghost-layer/src/index.ts index 02d83c9..a893896 100644 --- a/packages/ghost-layer/src/index.ts +++ b/packages/ghost-layer/src/index.ts @@ -7,7 +7,7 @@ async function main() { // 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 relay = new RelayNode(); const reader = new XahauJsonRpcReader("https://xahau.network"); console.log("👻 Waiting for intents from garner clients..."); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 29564f3..316c7d8 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -9,3 +9,4 @@ export * from "./resolver.js"; export * from "./dust.js"; export * from "./verifier.js"; export * from "./rank.js"; +export * from "./intentrank.js"; diff --git a/packages/sdk/src/intentrank.ts b/packages/sdk/src/intentrank.ts new file mode 100644 index 0000000..afc40d8 --- /dev/null +++ b/packages/sdk/src/intentrank.ts @@ -0,0 +1,94 @@ +/** + * IntentRank — reputation-weighted matching. (spec §4.2) + * + * The ranking function that decides which responder wins an intent: + * + * IntentRank(S) = Σ (fulfillment_value · proof_quality · recency_decay) + * ---------------------------------------------------------- + * 1 + Σ (failure_severity · recency_decay) + * + * Numerator rewards recent, well-proven, high-value fulfillments. Denominator + * penalizes recent failures weighted by severity; the `1 +` is Laplace + * smoothing so a brand-new service (no history) scores 0 rather than dividing + * by zero, and a single failure can't send the score to infinity. + * + * Pure + deterministic (injectable `now`), so matching is reproducible and + * auditable. No network, no dependency. + */ + +export interface Fulfillment { + /** Economic value delivered (rail smallest units). */ + value: number; + /** Provenance proof quality in [0,1] — e.g. ZK-verified = 1, unproven = 0. */ + proofQuality: number; + /** Unix seconds when the fulfillment settled. */ + timestamp: number; +} + +export interface Failure { + /** Severity multiplier (>0); a slash is heavier than a timeout. */ + severity: number; + /** Unix seconds when the failure occurred. */ + timestamp: number; +} + +export interface ServiceHistory { + /** DID of the responder service. */ + did: string; + fulfillments: Fulfillment[]; + failures: Failure[]; +} + +export interface IntentRankOptions { + /** Half-life of the recency decay, in days. Default 30. */ + halfLifeDays?: number; + now?: number; +} + +const DAY = 86_400; + +function recencyDecay(ageSeconds: number, halfLifeDays: number): number { + const ageDays = Math.max(0, ageSeconds) / DAY; + return Math.pow(0.5, ageDays / halfLifeDays); +} + +/** Compute the IntentRank score for one service's history. */ +export function intentRank(history: ServiceHistory, opts: IntentRankOptions = {}): number { + const now = opts.now ?? Math.floor(Date.now() / 1000); + const halfLife = opts.halfLifeDays ?? 30; + + let numerator = 0; + for (const f of history.fulfillments) { + if (f.value < 0 || f.proofQuality < 0 || f.proofQuality > 1) { + throw new Error("fulfillment value must be >=0 and proofQuality in [0,1]"); + } + numerator += f.value * f.proofQuality * recencyDecay(now - f.timestamp, halfLife); + } + + let penalty = 0; + for (const x of history.failures) { + if (x.severity <= 0) throw new Error("failure severity must be > 0"); + penalty += x.severity * recencyDecay(now - x.timestamp, halfLife); + } + + return numerator / (1 + penalty); +} + +export interface RankedService { + did: string; + score: number; +} + +/** + * Rank candidate services by IntentRank, descending. Ties break by DID for a + * deterministic, reproducible ordering (important for auditability and for + * agents independently arriving at the same winner). + */ +export function rankServices( + candidates: ServiceHistory[], + opts: IntentRankOptions = {}, +): RankedService[] { + return candidates + .map((c) => ({ did: c.did, score: intentRank(c, opts) })) + .sort((a, b) => b.score - a.score || a.did.localeCompare(b.did)); +} diff --git a/packages/sdk/test/intentrank.test.js b/packages/sdk/test/intentrank.test.js new file mode 100644 index 0000000..5a90e24 --- /dev/null +++ b/packages/sdk/test/intentrank.test.js @@ -0,0 +1,56 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { intentRank, rankServices } from "../dist/index.js"; + +const NOW = 1_700_000_000; +const did = (n) => `did:poi:xah:rHb9CJAWyB4rj91VRWn96DkukG4bwdtyT${n}`; + +test("empty history scores zero (no division by zero)", () => { + assert.equal(intentRank({ did: did("h"), fulfillments: [], failures: [] }, { now: NOW }), 0); +}); + +test("a recent well-proven fulfillment scores ~ value", () => { + const s = intentRank( + { did: did("h"), fulfillments: [{ value: 1000, proofQuality: 1, timestamp: NOW }], failures: [] }, + { now: NOW }, + ); + assert.ok(Math.abs(s - 1000) < 1e-6); +}); + +test("proof quality scales the contribution linearly", () => { + const full = intentRank({ did: did("h"), fulfillments: [{ value: 1000, proofQuality: 1, timestamp: NOW }], failures: [] }, { now: NOW }); + const half = intentRank({ did: did("h"), fulfillments: [{ value: 1000, proofQuality: 0.5, timestamp: NOW }], failures: [] }, { now: NOW }); + assert.ok(Math.abs(half - full / 2) < 1e-6); +}); + +test("recency decay halves a one-half-life-old fulfillment", () => { + const old = intentRank( + { did: did("h"), fulfillments: [{ value: 800, proofQuality: 1, timestamp: NOW - 30 * 86400 }], failures: [] }, + { now: NOW, halfLifeDays: 30 }, + ); + assert.ok(Math.abs(old - 400) < 1e-6); +}); + +test("failures reduce the score via the smoothed denominator", () => { + const clean = intentRank({ did: did("h"), fulfillments: [{ value: 1000, proofQuality: 1, timestamp: NOW }], failures: [] }, { now: NOW }); + const withFail = intentRank( + { did: did("h"), fulfillments: [{ value: 1000, proofQuality: 1, timestamp: NOW }], failures: [{ severity: 1, timestamp: NOW }] }, + { now: NOW }, + ); + assert.ok(withFail < clean); + assert.ok(Math.abs(withFail - 500) < 1e-6); // 1000 / (1 + 1) +}); + +test("rankServices orders by score desc, deterministic tie-break by DID", () => { + const a = { did: did("a"), fulfillments: [{ value: 500, proofQuality: 1, timestamp: NOW }], failures: [] }; + const b = { did: did("b"), fulfillments: [{ value: 900, proofQuality: 1, timestamp: NOW }], failures: [] }; + const c = { did: did("c"), fulfillments: [{ value: 500, proofQuality: 1, timestamp: NOW }], failures: [] }; + const ranked = rankServices([a, b, c], { now: NOW }); + assert.equal(ranked[0].did, did("b")); // highest score + assert.deepEqual([ranked[1].did, ranked[2].did], [did("a"), did("c")]); // tie -> DID order +}); + +test("invalid inputs are rejected", () => { + assert.throws(() => intentRank({ did: did("h"), fulfillments: [{ value: 1, proofQuality: 2, timestamp: NOW }], failures: [] }, { now: NOW })); + assert.throws(() => intentRank({ did: did("h"), fulfillments: [], failures: [{ severity: 0, timestamp: NOW }] }, { now: NOW })); +}); diff --git a/programs/poi-subscription/Cargo.toml b/programs/poi-subscription/Cargo.toml new file mode 100644 index 0000000..ddbe17c --- /dev/null +++ b/programs/poi-subscription/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "poi-subscription" +version = "0.1.0" +description = "Proof-of-Intent SaaS tier management (SOL subscriptions)" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib", "lib"] +name = "poi_subscription" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] + +[dependencies] +anchor-lang = { version = "0.30.1", features = ["init-if-needed"] } diff --git a/programs/poi-subscription/src/lib.rs b/programs/poi-subscription/src/lib.rs new file mode 100644 index 0000000..5fb36b6 --- /dev/null +++ b/programs/poi-subscription/src/lib.rs @@ -0,0 +1,188 @@ +//! poi_subscription — SaaS tier management (SOL). (spec §6.1, §6.2) +//! +//! Rail-access subscriptions. The company sells infrastructure access, not a +//! token and not a cut of payment volume (spec §1, §3.1): a wallet pays SOL to +//! the protocol treasury for a monthly tier, and the tier + expiry are recorded +//! on-chain. Free "Scout" needs no payment. +//! +//! Rail Miles (the loyalty program, §6.4) are deliberately NOT here — they are a +//! non-transferable, database-only bookkeeping entry in the commercial backend, +//! never on-chain, so they can never be mistaken for a token. +//! +//! Tiers + monthly price (spec §6.1): +//! Scout 0 SOL Runner 5 SOL Relay 25 SOL Builder 50 SOL +//! +//! NOTE: build with the Anchor/Solana SBF toolchain (`anchor build`). + +use anchor_lang::prelude::*; +use anchor_lang::system_program; + +declare_id!("9MeEYCFExtHAFiXa4ZFmW4nh34n3mk2hyqm91jDwSbEE"); + +pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000; +/// 30-day subscription window, in seconds. +pub const PERIOD_SECONDS: i64 = 30 * 86_400; + +#[program] +pub mod poi_subscription { + use super::*; + + /// One-time config: admin + SOL treasury. + pub fn initialize(ctx: Context) -> Result<()> { + let cfg = &mut ctx.accounts.config; + cfg.admin = ctx.accounts.admin.key(); + cfg.treasury = ctx.accounts.treasury.key(); + cfg.total_subscriptions = 0; + cfg.bump = ctx.bumps.config; + Ok(()) + } + + /// Subscribe (or upgrade/renew) to `tier`. Pays the tier's monthly SOL price + /// to the treasury and sets the expiry one period out. Scout is free and + /// simply records the tier with a rolling expiry. + pub fn subscribe(ctx: Context, tier: u8) -> Result<()> { + let price = tier_price_lamports(tier)?; + let cfg = &ctx.accounts.config; + require_keys_eq!(ctx.accounts.treasury.key(), cfg.treasury, SubError::WrongTreasury); + + if price > 0 { + system_program::transfer( + CpiContext::new( + ctx.accounts.system_program.to_account_info(), + system_program::Transfer { + from: ctx.accounts.subscriber.to_account_info(), + to: ctx.accounts.treasury.to_account_info(), + }, + ), + price, + )?; + } + + let now = Clock::get()?.unix_timestamp; + let sub = &mut ctx.accounts.subscription; + let is_new = sub.owner == Pubkey::default(); + sub.owner = ctx.accounts.subscriber.key(); + sub.tier = tier; + // Extend from the later of now or the existing expiry (renewals stack). + let base = core::cmp::max(now, sub.expiry); + sub.expiry = if tier == Tier::Scout as u8 { now + PERIOD_SECONDS } else { base + PERIOD_SECONDS }; + sub.bump = ctx.bumps.subscription; + + if is_new { + let cfg = &mut ctx.accounts.config; + cfg.total_subscriptions = cfg.total_subscriptions.saturating_add(1); + } + + emit!(Subscribed { subscriber: sub.owner, tier, expiry: sub.expiry, paid_lamports: price }); + Ok(()) + } +} + +/// SaaS tiers (spec §6.1). Stored as u8. +#[repr(u8)] +pub enum Tier { + Scout = 0, + Runner = 1, + Relay = 2, + Builder = 3, +} + +/// Monthly price in lamports for a tier; errors on an unknown tier. +pub fn tier_price_lamports(tier: u8) -> Result { + let sol = match tier { + x if x == Tier::Scout as u8 => 0, + x if x == Tier::Runner as u8 => 5, + x if x == Tier::Relay as u8 => 25, + x if x == Tier::Builder as u8 => 50, + _ => return err!(SubError::UnknownTier), + }; + Ok(sol * LAMPORTS_PER_SOL) +} + +#[account] +pub struct Config { + pub admin: Pubkey, + pub treasury: Pubkey, + pub total_subscriptions: u64, + pub bump: u8, +} +impl Config { + pub const SPACE: usize = 8 + 32 + 32 + 8 + 1; +} + +#[account] +pub struct Subscription { + pub owner: Pubkey, + pub tier: u8, + pub expiry: i64, + pub bump: u8, +} +impl Subscription { + pub const SPACE: usize = 8 + 32 + 1 + 8 + 1; +} + +#[event] +pub struct Subscribed { + pub subscriber: Pubkey, + pub tier: u8, + pub expiry: i64, + pub paid_lamports: u64, +} + +#[derive(Accounts)] +pub struct Initialize<'info> { + #[account(init, payer = admin, space = Config::SPACE, seeds = [b"config"], bump)] + pub config: Account<'info, Config>, + #[account(mut)] + pub admin: Signer<'info>, + /// CHECK: SOL transfer destination only. + pub treasury: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct Subscribe<'info> { + #[account(mut, seeds = [b"config"], bump = config.bump)] + pub config: Account<'info, Config>, + #[account( + init_if_needed, + payer = subscriber, + space = Subscription::SPACE, + seeds = [b"sub", subscriber.key().as_ref()], + bump + )] + pub subscription: Account<'info, Subscription>, + #[account(mut)] + pub subscriber: Signer<'info>, + /// CHECK: validated against config.treasury in the handler. + #[account(mut)] + pub treasury: UncheckedAccount<'info>, + pub system_program: Program<'info, System>, +} + +#[error_code] +pub enum SubError { + #[msg("unknown subscription tier")] + UnknownTier, + #[msg("treasury account does not match config")] + WrongTreasury, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tier_prices_match_spec_6_1() { + assert_eq!(tier_price_lamports(Tier::Scout as u8).unwrap(), 0); + assert_eq!(tier_price_lamports(Tier::Runner as u8).unwrap(), 5 * LAMPORTS_PER_SOL); + assert_eq!(tier_price_lamports(Tier::Relay as u8).unwrap(), 25 * LAMPORTS_PER_SOL); + assert_eq!(tier_price_lamports(Tier::Builder as u8).unwrap(), 50 * LAMPORTS_PER_SOL); + } + + #[test] + fn unknown_tier_is_rejected() { + assert!(tier_price_lamports(4).is_err()); + assert!(tier_price_lamports(255).is_err()); + } +}