diff --git a/README.md b/README.md index ed80e79..3a7e16a 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,9 @@ ERC-8004 defines three composable trust mechanisms; HyperDAG ships one curated d | ERC-8004 mechanism | HyperDAG default | How it works | |---|---|---| -| **Reputation** (delegated trust via on-chain attestations) | `IReputation` → `@hyperdag/reputation-zkp` | Per-agent RepID 0–10,000; writes go to the canonical `ReputationRegistry` (live above). Optional ZKP RepID circuit (Plonky3) for privacy-preserving score proofs. | +| **Reputation** (delegated trust via on-chain attestations) | `IReputation` → `@hyperdag/reputation-zkp` | Per-agent RepID 0–10,000; writes go to the canonical `ReputationRegistry` (live above). Selective-disclosure / private-ownership proofs via a Plonky3 STARK range-check today; the **roadmap-V2** circuit that binds the proof to the actual RepID-derivation transcript is in active development. | | **Validation** (independent re-execution / cross-check) | `IValidation` → `@hyperdag/validation-trinity` | BFT validator set with HITL graduation; cross-LLM agreement check (Phase 1.5) for factual / time-sensitive prompts; `IHallucination` veto sits in the same chain. | -| **TEE Attestation** (verifiable execution receipts) | `IValidation` extension *(roadmap V2)* | The ZKP RepID circuit provides a verifiable-attestation flavor today; first-class TEE-backed ValidationRegistry support is roadmap (see V2 below). | +| **TEE Attestation** (verifiable execution receipts) | `IValidation` extension *(roadmap V2)* | First-class TEE-backed ValidationRegistry support is roadmap (see V2 below). The Plonky3 STARK in `@hyperdag/reputation-zkp` today proves a narrow range claim (`repid > threshold`); binding the proof to the agent decision + HAL signals is also V2. | --- @@ -96,7 +96,7 @@ HDP is not a heavy wrapper. It is a lightweight kernel defining clean versioned | Interface | Default | Wraps | | :--- | :--- | :--- | | `IIdentity` | `@hyperdag/identity-erc8004` | ERC-8004 IdentityRegistry | -| `IReputation` | `@hyperdag/reputation-zkp` | ZKP RepID | +| `IReputation` | `@hyperdag/reputation-zkp` | On-chain RepID via ERC-8004 ReputationRegistry; ZKP for private-ownership / range proofs (Plonky3, V1 today — full V2 binds to RepID transcript) | | `IValidation` | `@hyperdag/validation-trinity` | BFT validators (with HITL graduation) | | `IPayment` | `@hyperdag/payment-x402` | x402 | | `ILinkage` | `@hyperdag/linkage-registry` | HDP Linkage Registry (inverse-stake curve) | @@ -115,15 +115,15 @@ graph TD Node2 & Node3 --> Node4{Merkle Hash} Node4 -->|ERC-8004| Chain[(HyperDAG Ledger)] - subgraph "Privacy Layer" - Chain --> ZKP[ZKP RepID Circuit] - ZKP --> Creds[Sovereign Credentials] + subgraph "Privacy Layer (V1: range-check today; V2: bound to RepID transcript)" + Chain --> ZKP[Plonky3 STARK Circuit] + ZKP --> Creds[Selective-disclosure proofs] end ``` ### Core building blocks - **Merkle DAG** — content-addressed, append-only verifiable state. -- **ZKP RepID** — privacy-preserving reputation proofs (Plonky3, BabyBear field, Poseidon2). +- **ZKP for private ownership** — Plonky3 STARK (BabyBear field, Keccak FRI) range-check today; roadmap-V2 circuit binds the proof to the agent decision + HAL signals + RepID-delta derivation. - **[ERC-8004](https://ethereum-magicians.org/t/erc-8004-trustless-agents/25098)** — standards-based identity + reputation for autonomous agents. - **[x402](https://github.com/x402-rs/x402-rs)** — agent-to-agent micropayments. - **[Plonky3](https://github.com/Plonky3/Plonky3)** — STARK proving, no trusted setup, fast browser verification. @@ -136,7 +136,7 @@ graph TD |---|---|---| | **V1 — Live today (Base Sepolia)** | shipping now | Six-interface modular kernel · `@hyperdag/protocol@0.1.0-alpha` on npm · IdentityRegistry + ReputationRegistry writing on Base Sepolia (4 agents, 32 attestations) · HAL pipeline + cross-LLM agreement · x402 payments. | | **V1.5 — User-managed permission guardrails** | 1–2 weeks | Telegram (and later email/discord/webhook) alerts when an agent attempts an action outside its lane. Six RepID-derived permission tiers (Probationary → Architect) map score to capability. Substrate is live; client SDK lands at install. | -| **V2 — Mainnet** | Q2 2026 | Canonical registries on Base mainnet · TEE-backed ValidationRegistry path · ZKP-federated learning (bilateral benefit) · expanded validator-set diversity. | +| **V2 — Mainnet** | Q2 2026 | Canonical registries on Base mainnet · TEE-backed ValidationRegistry path · **ZKP RepID circuit bound to agent decision + HAL signals + RepID-delta transcript (extension of today's Plonky3 range-check)** · ZKP-federated learning (bilateral benefit) · expanded validator-set diversity. | See [GOVERNANCE_ROADMAP.md](GOVERNANCE_ROADMAP.md) for the bootstrap-to-community handover timeline. diff --git a/packages/defaults/hallucination-hal-local/README.md b/packages/defaults/hallucination-hal-local/README.md new file mode 100644 index 0000000..d920e79 --- /dev/null +++ b/packages/defaults/hallucination-hal-local/README.md @@ -0,0 +1,74 @@ +# @hyperdag/hallucination-hal-local + +**Status:** Internal-only. `private: true`. **Do NOT npm publish without Sean's explicit approval.** + +Local-first implementation of the `IHallucination` slot. Bundles the deterministic 5-signal extractor + canonical combiner from `repid-engine/src/hal/lib/`, so a `createHDP({})` consumer can run HAL evaluations **offline, with no remote dependency**, for the C→A milestone (D-017). + +## Why private + +The HAL signal-extraction formula and weighting are part of HDP's patent portfolio (P-003 — Pythagorean Comma Dissonance Detection). Per CLAUDE.md hard-stop: + +> RepID scoring formula T=floor(2000×log₁₀…) — never appear in public docs +> ANFIS parameters — never in public docs + +This package contains the canonical 5-signal extraction formula AND the canonical combiner (Pythagorean Comma at 531441/524288). It is **safe for trinity-internal consumers** (the agents and services already inside the patent-aware codebase). It is **not** safe for public npm publication. + +The PUBLIC slot remains `@hyperdag/hallucination-hal`, which ships as an HTTP client + stub fallback. External consumers of `createHDP({})` continue to see that public package. + +## Architecture + +This package exports three classes: + +- **`LocalHALProvider`** — primary. Pure local 5-signal extraction + canonical Pythagorean-Comma combiner. Implements `IHallucination`. No network calls. The 5th signal (`agreement_score`, cross-LLM consensus) falls back to `null` when no providers are supplied — see [Cross-LLM signal](#cross-llm-signal-5th-signal) below. + +- **`RemoteHALProvider`** — re-export of `HALHallucinationProvider` from `@hyperdag/hallucination-hal` for API symmetry. + +- **`HALRouter`** — routing provider that respects `HDP_MODE`: + - `HDP_MODE=local` → only ever calls the local provider. + - `HDP_MODE=remote` → only ever calls the remote provider. + - `HDP_MODE=hybrid` (default) → tries local first; if the local result lands in a configurable veto-borderline zone (default `[0.20, 0.30]`), routes to remote for a second opinion. If remote is unreachable, returns the local result with `borderline_fallback: true` in `signals` (extra field). + +## Cross-LLM signal (5th signal) + +The cross-LLM `agreement_score` is the only signal that needs a model. Three paths: + +1. **No providers configured** → `agreement_score = null`, combiner falls back to the 4-signal formula (canonical `score.ts` already handles this branch). +2. **BYOK providers** → caller passes provider configs in `evaluate(request, { providers, embeddingClient })`. Same shape as `HALContext.providers` in the repid-engine source. +3. **Remote fallback** → `HALRouter` with `HDP_MODE=hybrid` will get cross-LLM coverage from the remote service when borderline. + +This package does **not** bundle a small local model. That would make the install size unreasonable. State of art: shipping local cross-LLM verification needs a 1-7B-param judge model, which is a 1-4 GB tarball minimum. Out of scope tonight; **honest fallback** to BYOK or remote. + +## Usage + +```ts +import { createHDP } from '@hyperdag/protocol'; +import { HALRouter } from '@hyperdag/hallucination-hal-local'; + +const hdp = createHDP({ + overrides: { + hallucination: new HALRouter({ mode: 'hybrid' }), + }, +}); + +const result = await hdp.hallucination.evaluate({ + prompt: 'What is the boiling point of water at sea level?', + output: 'It boils at 100°C at sea level.', + context: { domain: 'physics', certainty: 0.99 }, +}); +``` + +## Divergence from remote (A/B) + +The local provider matches the remote provider byte-for-byte on the deterministic 4 signals (same constants, same regex hit-lists, same Jaccard ontology overlap). The 5th signal (`agreement_score`) is the only divergence: + +| Mode | Where `agreement_score` comes from | +|---|---| +| Local, no providers | `null` (combiner falls back to 4-signal formula) | +| Local, BYOK providers | live LLM calls from the caller's process | +| Remote | live LLM calls from `repid-engine` (orchestrated) | + +For factual / time-sensitive prompts, local-no-providers will produce a different `hal_score` than remote, because remote always runs cross-LLM. **This divergence is documented in the A/B test results in the sprint report.** + +## License + +See `LICENSE` at the repo root. This package is Apache-2.0 in license terms but `private: true` in `package.json` so the npm registry refuses to publish it. diff --git a/packages/defaults/hallucination-hal-local/package.json b/packages/defaults/hallucination-hal-local/package.json new file mode 100644 index 0000000..6b92508 --- /dev/null +++ b/packages/defaults/hallucination-hal-local/package.json @@ -0,0 +1,39 @@ +{ + "name": "@hyperdag/hallucination-hal-local", + "version": "0.1.0-internal", + "private": true, + "description": "Local HAL provider — deterministic 5-signal extraction + canonical combiner. Internal-only. Public npm publish requires Sean's explicit approval per CLAUDE.md hard-stop on HAL signal-extraction formula + weighting (HAEE / ANFIS / P-003 patent portfolio).", + "license": "SEE LICENSE IN LICENSE", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "node --test --import tsx tests/*.test.ts" + }, + "peerDependencies": { + "@hyperdag/interfaces": "*", + "@hyperdag/hallucination-hal": "*" + }, + "devDependencies": { + "typescript": "^5.5.0", + "tsx": "^4.0.0" + }, + "keywords": [ + "hyperdag", + "hal", + "hallucination", + "internal" + ] +} diff --git a/packages/defaults/hallucination-hal-local/src/constants.ts b/packages/defaults/hallucination-hal-local/src/constants.ts new file mode 100644 index 0000000..db2f995 --- /dev/null +++ b/packages/defaults/hallucination-hal-local/src/constants.ts @@ -0,0 +1,92 @@ +/** + * HAL constants — ported verbatim from repid-engine/src/hal/lib/constants.ts. + * + * SACRED CONSTANTS — DO NOT MUTATE. + * + * Patent-load-bearing — this file is the reason the entire package is marked + * `private: true` in package.json. The HAL_PYTHAGOREAN_COMMA ratio, the + * formula weights, and the canonical signal field names are all part of + * HDP's patent portfolio (P-003). Public npm publish requires Sean's + * explicit approval per CLAUDE.md hard-stop. + * + * To preserve byte-equivalence with the production HAL, every constant here + * is a verbatim copy of the repid-engine source. Future drift between + * repid-engine and this package would compromise the A/B equivalence guarantee + * documented in README.md. + */ + +export const HAL_PYTHAGOREAN_COMMA: number = 531441 / 524288; + +export const HAL_FORMULA_WEIGHTS = { + harm: 0.4, + epistemic: 0.3, + evidence: 0.2, + scope: 0.1, +} as const; + +export const HAL_DEFAULT_VETO_THRESHOLD: number = 0.25; +export const HAL_CONSTITUTIONAL_BLOCK_THRESHOLD: number = 0.48; + +export const COMMA_BFT_THRESHOLDS = { + vetoGap: 0.05, + vetoAvg: 0.85, + majorGap: 0.10, + majorAvg: 0.75, + minorGap: 0.15, +} as const; + +export const COMMA_BAND_TIGHT_THRESHOLD: number = 0.99; +export const COMMA_BAND_LOOSE_THRESHOLD: number = 0.95; + +/** + * Domain ontology vocabularies. High-level definition only — caller can + * supply additional/custom ontologies via context. + */ +export const DEFAULT_DOMAIN_ONTOLOGIES: Record = { + 'cre-underwriting': [ + 'cap rate', 'noi', 'vacancy', 'absorption', 'ltv', 'dscr', 'irr', + 'cash-on-cash', 'basis points', 'underwriting', 'class a', 'class b', + 'industrial', 'office', 'retail', 'multifamily', 'operating expenses', + 'gross rent', 'debt service', 'net operating income', + ], + 'compliance': [ + 'regulation', 'statute', 'compliance', 'audit', 'disclosure', 'fiduciary', + 'sec', 'finra', 'gdpr', 'ccpa', 'ai act', 'liability', 'mandate', + 'enforcement', 'violation', 'risk management', 'governance', + ], + 'finance': [ + 'revenue', 'ebitda', 'margin', 'yield', 'return', 'risk', 'portfolio', + 'asset', 'liability', 'equity', 'debt', 'interest rate', 'inflation', + 'basis', 'spread', 'valuation', 'cash flow', 'projection', + ], + 'technical': [ + 'algorithm', 'model', 'training', 'inference', 'parameter', 'neural', + 'circuit', 'hash', 'proof', 'contract', 'token', 'consensus', + 'validation', 'cryptography', 'protocol', 'architecture', 'implementation', + ], + 'legal': [ + 'contract', 'clause', 'covenant', 'warranty', 'indemnification', 'lien', + 'title', 'easement', 'encumbrance', 'jurisdiction', 'statute', 'precedent', + ], +}; + +export const OVERCONFIDENCE_MARKERS: readonly string[] = [ + 'guaranteed', 'certain', 'definitive', 'proven', 'fact', + 'always', 'never', 'impossible', 'must', 'will definitely', + 'risk-free', 'no risk', '100%', 'without doubt', + 'everyone knows', 'obviously', 'clearly', 'undeniably', +]; + +/** + * Note the duplicate `approximately` at index 0 and again at the tail. + * This is preserved verbatim from the pre-extraction implementation + * (`src/services/hal-signals.ts:52-57` at HEAD `204cfcb`). Per repid-engine + * sprint hard rule #7 (no semantic changes to HAL behavior), this + * duplicate is preserved here too so A/B equivalence holds. + */ +export const EPISTEMIC_HEDGES: readonly string[] = [ + 'approximately', 'roughly', 'around', 'may', 'might', 'could', + 'likely', 'probably', 'suggest', 'indicate', 'appear', 'seem', + 'estimate', 'projection', 'forecast', 'assumption', 'according to', + 'based on', 'as of', 'reported', 'approximately', +]; diff --git a/packages/defaults/hallucination-hal-local/src/extract.ts b/packages/defaults/hallucination-hal-local/src/extract.ts new file mode 100644 index 0000000..0b4e0f2 --- /dev/null +++ b/packages/defaults/hallucination-hal-local/src/extract.ts @@ -0,0 +1,90 @@ +/** + * Path A 5-signal HAL extractor — pure, deterministic, no I/O. + * + * Ported verbatim from repid-engine/src/hal/lib/extract.ts (commit + * 204cfcbe93f85f8cb0ccdc969d2cc4003129c1db). Per the repid-engine sprint + * hard rules #7 + #8 forbidding behavior tuning, no edits beyond the import + * paths. The 369-assertion regression test in repid-engine holds the line + * on byte-equivalence. + */ + +import { + DEFAULT_DOMAIN_ONTOLOGIES, + EPISTEMIC_HEDGES, + OVERCONFIDENCE_MARKERS, +} from './constants.js'; +import type { ExtractInput, NativeHALSignals } from './types.js'; + +export function extractHALSignals(input: ExtractInput): NativeHALSignals { + const { text: claimText, domain, certainty } = input; + const ontologies = input.domainOntologies + ? { ...DEFAULT_DOMAIN_ONTOLOGIES, ...input.domainOntologies } + : DEFAULT_DOMAIN_ONTOLOGIES; + + const text = claimText.toLowerCase(); + const words = text.split(/\s+/); + const wordCount = words.length; + + // Signal 1: harm_probability + const overconfidenceCount = OVERCONFIDENCE_MARKERS + .filter(k => text.includes(k)).length; + const specificNumbers = ( + text.match(/\d+\.?\d*\s*(%|percent|basis|bps|billion|million)/g) || [] + ).length; + const harm_probability = Math.min( + 1, + (overconfidenceCount * 0.18) + + (specificNumbers * 0.08) + + (certainty > 0.92 && overconfidenceCount > 0 ? 0.2 : 0), + ); + + // Signal 2: epistemic_uncertainty + const hedgeCount = EPISTEMIC_HEDGES + .filter(k => text.includes(k)).length; + const hedgeDensity = hedgeCount / Math.max(wordCount / 8, 1); + let certaintyHedgeMismatch = + certainty > 0.88 && hedgeCount === 0 ? 0.35 : 0; + + if (domain === 'mathematics' || domain === 'cryptography') { + certaintyHedgeMismatch *= 0.30; + } + + let epistemic_uncertainty = Math.min( + 1, + Math.max(0, 0.45 - (hedgeDensity * 0.25) + certaintyHedgeMismatch), + ); + + if (domain === 'mathematics' || domain === 'cryptography') { + epistemic_uncertainty *= 0.15; + } + + // Signal 3: evidence_quality + const hasNumbers = /\d+/.test(text); + const hasTemporalRef = /\b(20\d\d|q[1-4]|january|february|march|april|may|june|july|august|september|october|november|december)\b/i.test(text); + const hasProperNouns = /\b[A-Z][a-z]{2,}(\s[A-Z][a-z]{2,})+/.test(claimText); + const lengthScore = Math.min(1, wordCount / 40); + const evidence_quality = Math.min( + 1, + (hasNumbers ? 0.25 : 0) + + (hasTemporalRef ? 0.20 : 0) + + (hasProperNouns ? 0.15 : 0) + + (lengthScore * 0.40), + ); + + // Signal 4: scope_appropriateness — Jaccard-like overlap with domain ontology. + const ontology = ontologies[domain] ?? ontologies['finance'] ?? []; + const matchCount = ontology + .filter(term => text.includes(term.toLowerCase())).length; + const scope_appropriateness = Math.min( + 1, + matchCount / Math.max(ontology.length * 0.25, 1), + ); + + return { + harm_probability, + epistemic_uncertainty, + evidence_quality, + scope_appropriateness, + certainty_at_claim: certainty, + }; +} diff --git a/packages/defaults/hallucination-hal-local/src/index.ts b/packages/defaults/hallucination-hal-local/src/index.ts new file mode 100644 index 0000000..306bb1a --- /dev/null +++ b/packages/defaults/hallucination-hal-local/src/index.ts @@ -0,0 +1,36 @@ +/** + * @hyperdag/hallucination-hal-local — internal local HAL provider. + * + * See README.md for the publish-gate constraints. `private: true`. + */ + +export { LocalHALProvider } from './providers.js'; +export type { LocalHALProviderConfig } from './providers.js'; + +export { HALRouter } from './router.js'; +export type { HALRouterConfig, HDPMode } from './router.js'; + +// Re-export the remote provider under a clear alias so consumers can name +// what they're getting. +export { HALHallucinationProvider as RemoteHALProvider } from '@hyperdag/hallucination-hal'; + +// Surface the native types + extractor so power users can drive the local +// extractor directly without the IHallucination wrapper. +export { extractHALSignals } from './extract.js'; +export { computeHALScore } from './score.js'; +export type { + NativeHALSignals, + ExtractInput, + LocalHALEvaluateOptions, + CommaSeverity, + AgreementZone, + StrictnessLevel, +} from './types.js'; + +// Surface the constants (read-only) so consumers can verify they're using +// the canonical values. +export { + HAL_PYTHAGOREAN_COMMA, + HAL_FORMULA_WEIGHTS, + HAL_DEFAULT_VETO_THRESHOLD, +} from './constants.js'; diff --git a/packages/defaults/hallucination-hal-local/src/providers.ts b/packages/defaults/hallucination-hal-local/src/providers.ts new file mode 100644 index 0000000..32df205 --- /dev/null +++ b/packages/defaults/hallucination-hal-local/src/providers.ts @@ -0,0 +1,148 @@ +/** + * LocalHALProvider — primary IHallucination implementation for C→A (D-017). + * + * Wraps the local 5-signal extractor + canonical combiner so a createHDP({}) + * consumer can run HAL evaluations entirely offline. The 5th signal + * (cross-LLM agreement) is honestly fallback-able: + * - If `request.context.agreement_score` is supplied, the 5-signal combiner runs. + * - Otherwise the 4-signal combiner runs (canonical score.ts already handles this). + * + * Schema mapping: the local provider's NATIVE signal block uses the + * repid-engine canonical 5-signal names (harm_probability, epistemic_uncertainty, + * evidence_quality, scope_appropriateness, certainty_at_claim). The public + * @hyperdag/interfaces HALSignals uses a different 6-DOF shape (faithfulness, + * contradiction, calibration, relevance, coherence, consensus). The native + * signals are surfaced as extras (`native_signals`) on the returned + * HALResult, and the canonical 6-DOF block is populated via best-effort + * approximation so downstream IHallucination consumers still work. + */ + +import { extractHALSignals } from './extract.js'; +import { computeHALScore } from './score.js'; +import { + HAL_PYTHAGOREAN_COMMA, + HAL_DEFAULT_VETO_THRESHOLD, +} from './constants.js'; +import type { NativeHALSignals } from './types.js'; +import type { + HALEvaluationRequest, + HALResult, + HALSignals, + IHallucination, + VetoDecision, +} from '@hyperdag/interfaces'; + +export interface LocalHALProviderConfig { + /** Default 0.25; overrides HAL_DEFAULT_VETO_THRESHOLD if set. */ + threshold?: number; + /** Override the Pythagorean Comma constant (only for ablation testing). */ + commaOverride?: number; + /** Caller-supplied domain ontology extensions / overrides. */ + domainOntologies?: Record; +} + +/** + * Project NATIVE 5-signal block → canonical 6-DOF HALSignals. + * + * This is a LOSSY approximation. The native and canonical schemas are + * intentionally different. Mapping rationale: + * + * - faithfulness ≈ evidence_quality (both measure "is the claim grounded") + * - contradiction ≈ harm_probability (both penalize overconfident specifics) + * - calibration ≈ 1 - epistemic_uncertainty (well-calibrated = low mismatch) + * - relevance ≈ scope_appropriateness (both = "does the claim fit the domain") + * - coherence ≈ 1 - epistemic_uncertainty (same proxy as calibration) + * - consensus = agreement_score ?? 0.5 (null → neutral midpoint) + * + * The native block is the source of truth for the local provider's veto + * decision (computed in score.ts). The 6-DOF block is informational only, + * for IHallucination-typed dashboards and validators. + */ +function projectToCanonical(native: NativeHALSignals): HALSignals { + return { + faithfulness: native.evidence_quality, + contradiction: native.harm_probability, + calibration: 1 - native.epistemic_uncertainty, + relevance: native.scope_appropriateness, + coherence: 1 - native.epistemic_uncertainty, + consensus: native.agreement_score ?? 0.5, + }; +} + +export class LocalHALProvider implements IHallucination { + private readonly threshold: number; + private readonly commaOverride?: number; + private readonly domainOntologies?: Record; + + constructor(config: LocalHALProviderConfig = {}) { + this.threshold = config.threshold ?? HAL_DEFAULT_VETO_THRESHOLD; + this.commaOverride = config.commaOverride; + this.domainOntologies = config.domainOntologies; + } + + async evaluate(request: HALEvaluationRequest): Promise { + const ctx = (request.context ?? {}) as Record; + const domain = (ctx.domain as string | undefined) ?? 'finance'; + const certainty = typeof ctx.certainty === 'number' ? ctx.certainty : 0.5; + + // 4 deterministic signals on the OUTPUT (the claim under evaluation). + const baseSignals = extractHALSignals({ + text: request.output, + domain, + certainty, + domainOntologies: this.domainOntologies, + }); + + // Caller may pre-compute the 5th (cross-LLM agreement) signal — local + // does not call out to LLMs for this BYOK path. + const agreement_score = + typeof ctx.agreement_score === 'number' + ? (ctx.agreement_score as number) + : null; + + const nativeSignals: NativeHALSignals = { + ...baseSignals, + agreement_score, + prompt_category: (ctx.prompt_category as string | undefined) ?? null, + }; + + const scored = computeHALScore(nativeSignals, this.threshold, this.commaOverride); + const canonicalSignals = projectToCanonical(nativeSignals); + + // comma_gap in this local path is the gap between the unscaled sum and + // the comma threshold — not the cross-LLM belief spread. For that, run + // the cross-LLM step (remote). Surface as null when no agreement signal. + const comma_gap = scored.hal_score - (scored.threshold); + + return { + hal_score: scored.hal_score, + vetoed: scored.vetoed, + veto_reason: scored.vetoed + ? `local-canonical: hal_score=${scored.hal_score.toFixed(3)} >= threshold=${scored.threshold}` + : undefined, + // Local provider does not produce a separate "comma_veto" — it always + // uses the comma in the score multiplier. The cross-LLM BFT comma_veto + // path requires multiple LLM beliefs (remote-only in v1). + comma_veto: false, + comma_gap, + formula: scored.formula, + signals: canonicalSignals, + // Extra field — IHallucination contract MAY add fields beyond the 6-DOF. + // The native block is the source of truth for veto. + ...({ native_signals: nativeSignals } as object), + }; + } + + async vetoCheck(_output: string, consensus: number): Promise { + // The local-light path: given a precomputed consensus value, return the + // gap-vs-Pythagorean-Comma decision per the canonical formula in the + // existing remote provider's vetoCheck (gap = consensus - (comma - 1)). + const commaThreshold = await this.getCommaThreshold(); + const gap = consensus - (commaThreshold - 1); + return { vetoed: gap < 0, comma_gap: gap }; + } + + async getCommaThreshold(): Promise { + return this.commaOverride ?? HAL_PYTHAGOREAN_COMMA; + } +} diff --git a/packages/defaults/hallucination-hal-local/src/router.ts b/packages/defaults/hallucination-hal-local/src/router.ts new file mode 100644 index 0000000..23cd590 --- /dev/null +++ b/packages/defaults/hallucination-hal-local/src/router.ts @@ -0,0 +1,128 @@ +/** + * HALRouter — single IHallucination implementation that routes between the + * local primary and the remote fallback based on HDP_MODE. + * + * mode = 'local' → only LocalHALProvider + * mode = 'remote' → only HALHallucinationProvider (the existing HTTP client) + * mode = 'hybrid' (default): + * 1. Run local. + * 2. If hal_score lands in [borderline_low, borderline_high] + * (default [0.20, 0.30]), AND a remote URL is configured, run + * remote and return its result with `{ borderline_routed: true }` + * spliced into the signals extras. + * 3. Otherwise return the local result. + * + * Remote unreachable in hybrid mode → return the local result with + * `{ borderline_fallback: true }` (honest about the fallback). + * + * Config precedence: explicit constructor opt > env var > default. + */ + +import { LocalHALProvider, type LocalHALProviderConfig } from './providers.js'; +import { HALHallucinationProvider } from '@hyperdag/hallucination-hal'; +import type { + HALEvaluationRequest, + HALResult, + IHallucination, + VetoDecision, +} from '@hyperdag/interfaces'; + +export type HDPMode = 'local' | 'remote' | 'hybrid'; + +export interface HALRouterConfig { + mode?: HDPMode; + borderlineLow?: number; + borderlineHigh?: number; + localConfig?: LocalHALProviderConfig; + // Remote (existing HTTP client) config — same shape as + // HALHallucinationProviderConfig in @hyperdag/hallucination-hal. + remoteConfig?: { + apiUrl?: string; + apiKey?: string; + timeoutMs?: number; + commaThreshold?: number; + }; +} + +function resolveMode(opt?: HDPMode): HDPMode { + if (opt) return opt; + const env = (globalThis as any).process?.env?.HDP_MODE as string | undefined; + if (env === 'local' || env === 'remote' || env === 'hybrid') return env; + return 'hybrid'; +} + +export class HALRouter implements IHallucination { + private readonly mode: HDPMode; + private readonly borderlineLow: number; + private readonly borderlineHigh: number; + private readonly local: LocalHALProvider; + private readonly remote: HALHallucinationProvider; + private readonly remoteHasUrl: boolean; + + constructor(config: HALRouterConfig = {}) { + this.mode = resolveMode(config.mode); + this.borderlineLow = config.borderlineLow ?? 0.20; + this.borderlineHigh = config.borderlineHigh ?? 0.30; + this.local = new LocalHALProvider(config.localConfig); + this.remote = new HALHallucinationProvider(config.remoteConfig ?? {}); + this.remoteHasUrl = Boolean(config.remoteConfig?.apiUrl); + } + + async evaluate(request: HALEvaluationRequest): Promise { + if (this.mode === 'remote') { + return this.remote.evaluate(request); + } + + // local + hybrid both run local first. + const localResult = await this.local.evaluate(request); + + if (this.mode === 'local') return localResult; + + // hybrid: borderline check. + const inBorderline = + localResult.hal_score >= this.borderlineLow && + localResult.hal_score <= this.borderlineHigh; + + if (!inBorderline || !this.remoteHasUrl) { + return localResult; + } + + try { + const remoteResult = await this.remote.evaluate(request); + return { + ...remoteResult, + ...({ + borderline_routed: true, + local_hal_score: localResult.hal_score, + } as object), + }; + } catch { + // Remote unreachable in hybrid mode — honest fallback to local. + return { + ...localResult, + ...({ + borderline_fallback: true, + attempted_remote: true, + } as object), + }; + } + } + + async vetoCheck(output: string, consensus: number): Promise { + if (this.mode === 'remote') return this.remote.vetoCheck(output, consensus); + return this.local.vetoCheck(output, consensus); + } + + async getCommaThreshold(): Promise { + // Both providers return the Pythagorean Comma by default; expose + // whichever the active mode uses. Hybrid returns local's value (the + // local path is the "primary"). + if (this.mode === 'remote') return this.remote.getCommaThreshold(); + return this.local.getCommaThreshold(); + } + + /** Diagnostic — what mode is this router actually using right now? */ + getMode(): HDPMode { + return this.mode; + } +} diff --git a/packages/defaults/hallucination-hal-local/src/score.ts b/packages/defaults/hallucination-hal-local/src/score.ts new file mode 100644 index 0000000..9415d61 --- /dev/null +++ b/packages/defaults/hallucination-hal-local/src/score.ts @@ -0,0 +1,69 @@ +/** + * HAL score computation — the canonical Path A formula. Pure function, + * dependency-free. + * + * Ported verbatim from repid-engine/src/hal/lib/score.ts. Patent-load-bearing + * per HAL_LIBRARY_API.md. + * + * hal_score = ( + * 0.4 * harm_probability + * + 0.3 * epistemic_uncertainty + * + 0.2 * (1 - evidence_quality) + * + 0.1 * (1 - scope_appropriateness) + * ) * (531441/524288) ← Pythagorean Comma + * + * When `agreement_score` is supplied, the 5-signal variant runs: + * sum = + * 0.35 * harm + * + 0.25 * epistemic + * + 0.15 * (1 - evidence) + * + 0.05 * (1 - scope) + * + 0.20 * (1 - agreement_score) + */ +import { + HAL_FORMULA_WEIGHTS, + HAL_PYTHAGOREAN_COMMA, + HAL_DEFAULT_VETO_THRESHOLD, +} from './constants.js'; +import type { NativeHALSignals } from './types.js'; + +export interface HALScoreOutput { + hal_score: number; + vetoed: boolean; + threshold: number; + formula: string; +} + +export function computeHALScore( + signals: NativeHALSignals, + threshold: number = HAL_DEFAULT_VETO_THRESHOLD, + commaOverride?: number, +): HALScoreOutput { + const w = HAL_FORMULA_WEIGHTS; + const comma = commaOverride !== undefined ? commaOverride : HAL_PYTHAGOREAN_COMMA; + let sum = 0; + if (typeof signals.agreement_score === 'number' && signals.agreement_score !== null) { + sum = + 0.35 * signals.harm_probability + + 0.25 * signals.epistemic_uncertainty + + 0.15 * (1 - signals.evidence_quality) + + 0.05 * (1 - signals.scope_appropriateness) + + 0.20 * (1 - signals.agreement_score); + } else { + sum = + w.harm * signals.harm_probability + + w.epistemic * signals.epistemic_uncertainty + + w.evidence * (1 - signals.evidence_quality) + + w.scope * (1 - signals.scope_appropriateness); + } + + const normalizedSum = Math.max(0, Math.min(1, sum)); + const hal_score = Math.min(1, normalizedSum * comma); + + return { + hal_score, + vetoed: hal_score >= threshold, + threshold, + formula: 'hal-canonical-v1', + }; +} diff --git a/packages/defaults/hallucination-hal-local/src/types.ts b/packages/defaults/hallucination-hal-local/src/types.ts new file mode 100644 index 0000000..06b35be --- /dev/null +++ b/packages/defaults/hallucination-hal-local/src/types.ts @@ -0,0 +1,50 @@ +/** + * Local HAL native types — the canonical 5-signal shape ported from + * repid-engine/src/hal/lib/types.ts. + * + * Field names patent-load-bearing per HAL_LIBRARY_API.md. The repid-engine + * names are preserved here for byte-equivalence with production HAL. + * + * Note: this is DIFFERENT from the 6-DOF `HALSignals` shape in + * `@hyperdag/interfaces` (faithfulness, contradiction, calibration, …). + * The two schemas are intentionally distinct: one is the proprietary native + * signal block, the other is the public canonical-DOF block. The + * `LocalHALProvider.evaluate()` populates both (native via extras, + * canonical via best-effort approximation) so consumers of either schema + * still work. + */ + +export type CommaSeverity = 'none' | 'minor' | 'major' | 'critical'; +export type AgreementZone = 'too-tight' | 'in-band' | 'too-loose'; +export type StrictnessLevel = 1 | 2 | 3 | 4 | 5; + +export interface NativeHALSignals { + harm_probability: number; + epistemic_uncertainty: number; + evidence_quality: number; + scope_appropriateness: number; + certainty_at_claim: number; + agreement_score?: number | null; + prompt_category?: string | null; + comma_veto?: boolean | null; + comma_gap?: number | null; + comma_severity?: CommaSeverity | null; +} + +export interface ExtractInput { + text: string; + domain: string; + certainty: number; + domainOntologies?: Record; +} + +export interface LocalHALEvaluateOptions { + /** Default 0.25; overrides HAL_DEFAULT_VETO_THRESHOLD if set. */ + threshold?: number; + /** Caller-supplied cross-LLM agreement score in [0, 1] if pre-computed. */ + agreement_score?: number | null; + /** Caller-supplied prompt category from a classifier. */ + prompt_category?: string | null; + /** Domain extension/override. */ + domainOntologies?: Record; +} diff --git a/packages/defaults/hallucination-hal-local/tests/smoke-direct.mjs b/packages/defaults/hallucination-hal-local/tests/smoke-direct.mjs new file mode 100644 index 0000000..bbdbd22 --- /dev/null +++ b/packages/defaults/hallucination-hal-local/tests/smoke-direct.mjs @@ -0,0 +1,96 @@ +/** + * Standalone smoke that loads the deterministic logic directly from src/ + * using a tiny tsc one-shot. No peer deps; no external @hyperdag/* imports + * needed for the smoke (those are only used by providers.ts and router.ts). + * + * Run from the package root: + * node tests/smoke-direct.mjs + * + * Expected: 4/4 cases produce sane signals + scores matching the + * src-of-truth in repid-engine/src/hal/lib (byte-equivalent port). + */ + +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pkgRoot = path.resolve(here, '..'); +const srcDir = path.join(pkgRoot, 'src'); +const distSmokeDir = path.join(pkgRoot, '.smoke-dist'); + +// Strip type-only imports + ".js" suffixes to make src directly loadable +// as ESM via dynamic import after compilation. We'll do a minimal tsc. +function ensureClean(dir) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} +} +ensureClean(distSmokeDir); +fs.mkdirSync(distSmokeDir, { recursive: true }); + +// Write a one-shot tsconfig so we don't pollute the main config. +const oneShotTs = path.join(pkgRoot, '.tsconfig.smoke.json'); +fs.writeFileSync( + oneShotTs, + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + declaration: false, + sourceMap: false, + outDir: '.smoke-dist', + rootDir: 'src', + strict: false, + skipLibCheck: true, + esModuleInterop: true, + }, + include: ['src/extract.ts', 'src/score.ts', 'src/constants.ts', 'src/types.ts'], + }, null, 2) +); + +// Use the cc1-mainnet repo's tsc binary (no network install needed). +const tsc = path.resolve(pkgRoot, '..', '..', '..', '..', 'repid-engine-cc1-mainnet', 'node_modules', '.bin', 'tsc.cmd'); +const tscArgs = ['--project', '.tsconfig.smoke.json']; + +const tscResult = spawnSync(tsc, tscArgs, { cwd: pkgRoot, stdio: 'inherit', shell: true }); +if (tscResult.status !== 0) { + console.error('tsc failed; aborting smoke.'); + process.exit(2); +} + +// Now load and run the smoke against the compiled .smoke-dist/ +const distMain = path.join(distSmokeDir, 'extract.js'); +const distScore = path.join(distSmokeDir, 'score.js'); +const distConst = path.join(distSmokeDir, 'constants.js'); + +const { extractHALSignals } = await import('file:///' + distMain.replace(/\\/g, '/')); +const { computeHALScore } = await import('file:///' + distScore.replace(/\\/g, '/')); +const { HAL_PYTHAGOREAN_COMMA } = await import('file:///' + distConst.replace(/\\/g, '/')); + +const cases = [ + { name: 'HIGH RISK — overconfident false CRE claim', + text: 'LA industrial cap rates definitely compressed 4.8% in 2024, proven fact.', domain: 'cre-underwriting', certainty: 0.95 }, + { name: 'LOW RISK — appropriately hedged CRE claim', + text: 'Based on CBRE data, industrial cap rates in LA may have compressed approximately 0.5-1.2% in Q3 2024.', domain: 'cre-underwriting', certainty: 0.82 }, + { name: 'HIGH RISK — false technical claim, no hedges', + text: 'Plonky3 definitely uses BN254 elliptic curve with Groth16, absolutely requires trusted setup.', domain: 'technical', certainty: 0.92 }, + { name: 'LOW RISK — verifiable mathematical truth', + text: 'The Pythagorean Comma ratio is exactly 531441/524288, approximately 1.01364, representing the gap between twelve perfect fifths and seven octaves.', domain: 'technical', certainty: 0.99 }, +]; + +console.log('\n=== LOCAL HAL SMOKE (deterministic 4-signal + canonical combiner) ===\n'); +console.log(`HAL_PYTHAGOREAN_COMMA = ${HAL_PYTHAGOREAN_COMMA}\n`); + +for (const c of cases) { + const s = extractHALSignals({ text: c.text, domain: c.domain, certainty: c.certainty }); + const out = computeHALScore(s); + console.log(c.name); + console.log(` signals: harm=${s.harm_probability.toFixed(3)} epi=${s.epistemic_uncertainty.toFixed(3)} ev=${s.evidence_quality.toFixed(3)} scope=${s.scope_appropriateness.toFixed(3)}`); + console.log(` hal_score=${out.hal_score.toFixed(4)} vetoed=${out.vetoed}\n`); +} + +// Cleanup +ensureClean(distSmokeDir); +fs.unlinkSync(oneShotTs); +console.log('=== smoke OK ==='); diff --git a/packages/defaults/hallucination-hal-local/tests/smoke.mjs b/packages/defaults/hallucination-hal-local/tests/smoke.mjs new file mode 100644 index 0000000..0ae882c --- /dev/null +++ b/packages/defaults/hallucination-hal-local/tests/smoke.mjs @@ -0,0 +1,49 @@ +/** + * Smoke test — runs the local provider on the same 4 cases the repid-engine + * `runValidation()` demo uses, and prints results. The expectation: identical + * hal_score and veto decision to the canonical formula, because this package + * ports extract.ts + score.ts + constants.ts byte-for-byte. + * + * Run after `npm run build`: + * node tests/smoke.mjs + */ + +import { extractHALSignals, computeHALScore, HAL_PYTHAGOREAN_COMMA } from '../dist/index.js'; + +const cases = [ + { + name: 'HIGH RISK — overconfident false CRE claim', + text: 'LA industrial cap rates definitely compressed 4.8% in 2024, proven fact.', + domain: 'cre-underwriting', + certainty: 0.95, + }, + { + name: 'LOW RISK — appropriately hedged CRE claim', + text: 'Based on CBRE data, industrial cap rates in LA may have compressed approximately 0.5-1.2% in Q3 2024.', + domain: 'cre-underwriting', + certainty: 0.82, + }, + { + name: 'HIGH RISK — false technical claim, no hedges', + text: 'Plonky3 definitely uses BN254 elliptic curve with Groth16, absolutely requires trusted setup.', + domain: 'technical', + certainty: 0.92, + }, + { + name: 'LOW RISK — verifiable mathematical truth', + text: 'The Pythagorean Comma ratio is exactly 531441/524288, approximately 1.01364, representing the gap between twelve perfect fifths and seven octaves.', + domain: 'technical', + certainty: 0.99, + }, +]; + +console.log('=== LOCAL HAL SMOKE TEST (no LLM keys, no network) ===\n'); +console.log(`HAL_PYTHAGOREAN_COMMA = ${HAL_PYTHAGOREAN_COMMA} (expected ${531441/524288})\n`); + +for (const c of cases) { + const signals = extractHALSignals({ text: c.text, domain: c.domain, certainty: c.certainty }); + const score = computeHALScore(signals); + console.log(c.name); + console.log(` signals: harm=${signals.harm_probability.toFixed(3)} epi=${signals.epistemic_uncertainty.toFixed(3)} ev=${signals.evidence_quality.toFixed(3)} scope=${signals.scope_appropriateness.toFixed(3)}`); + console.log(` hal_score=${score.hal_score.toFixed(4)} vetoed=${score.vetoed} formula=${score.formula}\n`); +} diff --git a/packages/defaults/hallucination-hal-local/tsconfig.json b/packages/defaults/hallucination-hal-local/tsconfig.json new file mode 100644 index 0000000..5400c57 --- /dev/null +++ b/packages/defaults/hallucination-hal-local/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "tests"] +} diff --git a/packages/defaults/identity-erc8004-viem/README.md b/packages/defaults/identity-erc8004-viem/README.md new file mode 100644 index 0000000..827c153 --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/README.md @@ -0,0 +1,58 @@ +# @hyperdag/identity-erc8004-viem + +Zero-config viem-backed ERC-8004 identity provider. Designed to be the +**local primary** behind the `identity` slot of `createHDP()`, with the +existing `@hyperdag/identity-erc8004` (the thin wrapper that requires a +caller-supplied client) usable as a remote-fallback / power-user surface. + +## Defaults (Base Sepolia) + +| Setting | Default | +|---|---| +| RPC URL | `https://sepolia.base.org` (public Base Sepolia RPC) | +| IdentityRegistry | `0x8004A818BFB912233c491871b3d84c89A494BD9e` | +| ReputationRegistry | `0x8004B663056A597Dffe9eCcC1965A193B7388713` (read-only) | +| Chain ID | `84532` | + +All defaults overridable via constructor opts. + +## Modes + +- **read-only** (no signer) — `getAgent`, `ownerOf`, `tokenURI`, `getAgentWallet`, `balanceOf`, `getMetadata`, `getReputation` all work zero-config. +- **eoa-write** (caller supplies a private key OR a viem WalletClient) — `register`, `setAgentURI`, `transfer` available. Calls throw `MissingSignerError` with a one-liner example if a write is attempted in read-only mode. +- **broadcast-disabled** (testing) — `WriteGated.dryRun = true` collects the encoded calldata + intended target instead of broadcasting. Used by tests and the sprint smoke. **Default for safety.** + +## Mainnet posture + +`allowMainnet: true` must be passed explicitly to target `chainId=8453`. Belt-and-braces against accidental real-money writes during local dev. + +## Usage + +```ts +import { createHDP } from '@hyperdag/protocol'; +import { ViemIdentityProvider } from '@hyperdag/identity-erc8004-viem'; + +// Zero-config read (no signer needed) +const hdp = createHDP({ + overrides: { identity: new ViemIdentityProvider({}) }, +}); +const file = await hdp.identity.resolve(3747n); +console.log(file.owner, file.metadataUri); + +// Signed writes (BYO private key) +const provider = new ViemIdentityProvider({ + privateKey: process.env.AGENT_PRIVATE_KEY as `0x${string}`, +}); +const { agentId, txHash } = await provider.register({ metadataUri: 'ipfs://…' }); +``` + +## What this package does NOT do + +- **Bundle viem.** viem is a peer-dependency. The consumer installs it. +- **Sign mainnet writes by default.** `allowMainnet: true` required. +- **Bundle private keys, secrets, or a wallet.** Caller must supply. +- **Broadcast in `dryRun=true`** (default). + +## License + +Apache-2.0. Thin wrapper around public ABIs; no proprietary logic. diff --git a/packages/defaults/identity-erc8004-viem/package.json b/packages/defaults/identity-erc8004-viem/package.json new file mode 100644 index 0000000..9c393d5 --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/package.json @@ -0,0 +1,36 @@ +{ + "name": "@hyperdag/identity-erc8004-viem", + "version": "0.1.0-alpha", + "description": "Zero-config viem-backed ERC-8004 identity provider for Base Sepolia (read works out of the box; signed-write requires explicit signer).", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "peerDependencies": { + "@hyperdag/interfaces": "*", + "@hyperdag/identity-erc8004": "*", + "viem": "^2.21.0" + }, + "devDependencies": { + "typescript": "^5.5.0" + }, + "keywords": [ + "hyperdag", + "erc-8004", + "identity", + "viem" + ] +} diff --git a/packages/defaults/identity-erc8004-viem/src/abi.ts b/packages/defaults/identity-erc8004-viem/src/abi.ts new file mode 100644 index 0000000..d7922f5 --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/src/abi.ts @@ -0,0 +1,91 @@ +/** + * Minimal ERC-8004 ABI surface — the functions the IIdentity wrapper + * actually calls. Full ABIs live at + * `packages/contracts/abis/{IdentityRegistry,ReputationRegistry}.json`. + * + * Keeping a hand-curated subset here lets us avoid `import x from './x.json'` + * + a tsconfig resolveJsonModule dance, AND keeps the bundle smaller. + */ + +export const IDENTITY_REGISTRY_ABI = [ + // ERC-721 reads + { + name: 'ownerOf', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'tokenId', type: 'uint256' }], + outputs: [{ name: '', type: 'address' }], + }, + { + name: 'balanceOf', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'owner', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, + { + name: 'tokenURI', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'tokenId', type: 'uint256' }], + outputs: [{ name: '', type: 'string' }], + }, + // ERC-8004 reads + { + name: 'getAgentWallet', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'agentId', type: 'uint256' }], + outputs: [{ name: '', type: 'address' }], + }, + { + name: 'getMetadata', + type: 'function', + stateMutability: 'view', + inputs: [ + { name: 'agentId', type: 'uint256' }, + { name: 'metadataKey', type: 'string' }, + ], + outputs: [{ name: '', type: 'bytes' }], + }, + // ERC-8004 writes + { + name: 'register', + type: 'function', + stateMutability: 'nonpayable', + inputs: [{ name: 'agentURI', type: 'string' }], + outputs: [{ name: 'agentId', type: 'uint256' }], + }, + { + name: 'setAgentURI', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'agentId', type: 'uint256' }, + { name: 'newURI', type: 'string' }, + ], + outputs: [], + }, + // ERC-721 writes + { + name: 'transferFrom', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'tokenId', type: 'uint256' }, + ], + outputs: [], + }, + // Events + { + name: 'Registered', + type: 'event', + inputs: [ + { name: 'agentId', type: 'uint256', indexed: false }, + { name: 'agentURI', type: 'string', indexed: false }, + { name: 'owner', type: 'address', indexed: false }, + ], + }, +] as const; diff --git a/packages/defaults/identity-erc8004-viem/src/constants.ts b/packages/defaults/identity-erc8004-viem/src/constants.ts new file mode 100644 index 0000000..a3a7d6a --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/src/constants.ts @@ -0,0 +1,23 @@ +/** + * ERC-8004 canonical addresses — Base Sepolia (chainId 84532). + * + * Pulled from CLAUDE.md / XC functional audit 2026-05-28. These are the + * upgradeable UUPS deployments operated by the HyperDAG team and are the + * "canonical" addresses external consumers should target. + */ + +export const BASE_SEPOLIA_CHAIN_ID = 84532; +export const BASE_MAINNET_CHAIN_ID = 8453; + +export const BASE_SEPOLIA_DEFAULT_RPC = 'https://sepolia.base.org'; +export const BASE_MAINNET_DEFAULT_RPC = 'https://mainnet.base.org'; + +export const IDENTITY_REGISTRY_BASE_SEPOLIA = + '0x8004A818BFB912233c491871b3d84c89A494BD9e' as const; + +export const REPUTATION_REGISTRY_BASE_SEPOLIA = + '0x8004B663056A597Dffe9eCcC1965A193B7388713' as const; + +/** Mainnet ReputationRegistry per memory entry x402-real-signing-2026-05-23. */ +export const REPUTATION_REGISTRY_BASE_MAINNET = + '0x8004BAa17C55a88189AE136b182e5fdA19dE9b63' as const; diff --git a/packages/defaults/identity-erc8004-viem/src/errors.ts b/packages/defaults/identity-erc8004-viem/src/errors.ts new file mode 100644 index 0000000..95f2870 --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/src/errors.ts @@ -0,0 +1,51 @@ +/** + * Custom error classes — keep them named so callers can `instanceof` them + * and surface clear, actionable messages. + */ + +export class MissingSignerError extends Error { + constructor() { + super( + 'ViemIdentityProvider: write attempted in read-only mode.\n' + + 'Supply a privateKey or walletClient when constructing the provider:\n' + + " new ViemIdentityProvider({ privateKey: '0x…' as `0x${string}` })\n" + + ' // or\n' + + ' new ViemIdentityProvider({ walletClient: myWalletClient })' + ); + this.name = 'MissingSignerError'; + } +} + +export class MainnetGuardError extends Error { + constructor(chainId: number) { + super( + `ViemIdentityProvider: write to mainnet chainId=${chainId} blocked.\n` + + 'Pass `{ allowMainnet: true }` to opt in. This guard prevents accidental ' + + 'real-money writes during local development.' + ); + this.name = 'MainnetGuardError'; + } +} + +export class DryRunBlocked extends Error { + /** The intended call so the test/dev can inspect what would have happened. */ + readonly intended: { + address: `0x${string}`; + functionName: string; + args: readonly unknown[]; + }; + constructor(intended: DryRunBlocked['intended']) { + super( + `ViemIdentityProvider: dryRun=true; broadcast suppressed for ${intended.functionName}.` + ); + this.name = 'DryRunBlocked'; + this.intended = intended; + } +} + +export class AgentNotFoundError extends Error { + constructor(agentId: bigint) { + super(`ViemIdentityProvider: agentId ${agentId} not found on registry.`); + this.name = 'AgentNotFoundError'; + } +} diff --git a/packages/defaults/identity-erc8004-viem/src/index.ts b/packages/defaults/identity-erc8004-viem/src/index.ts new file mode 100644 index 0000000..85fed07 --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/src/index.ts @@ -0,0 +1,21 @@ +export { ViemIdentityProvider } from './provider.js'; +export type { ViemIdentityProviderConfig } from './provider.js'; + +export { + MissingSignerError, + MainnetGuardError, + DryRunBlocked, + AgentNotFoundError, +} from './errors.js'; + +export { + BASE_SEPOLIA_CHAIN_ID, + BASE_MAINNET_CHAIN_ID, + BASE_SEPOLIA_DEFAULT_RPC, + BASE_MAINNET_DEFAULT_RPC, + IDENTITY_REGISTRY_BASE_SEPOLIA, + REPUTATION_REGISTRY_BASE_SEPOLIA, + REPUTATION_REGISTRY_BASE_MAINNET, +} from './constants.js'; + +export { IDENTITY_REGISTRY_ABI } from './abi.js'; diff --git a/packages/defaults/identity-erc8004-viem/src/provider.ts b/packages/defaults/identity-erc8004-viem/src/provider.ts new file mode 100644 index 0000000..50e95f8 --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/src/provider.ts @@ -0,0 +1,269 @@ +/** + * ViemIdentityProvider — zero-config ERC-8004 IIdentity implementation. + * + * Reads work without any signer: just construct and call `resolve()`. Writes + * require an explicit signer (privateKey or walletClient). Mainnet writes + * additionally require `allowMainnet: true`. + * + * The `dryRun` flag (default true) suppresses actual broadcasts and instead + * throws `DryRunBlocked` carrying the intended call payload. This is the + * safety net for sprint smoke tests + first-run dogfood — no accidental + * on-chain writes. + */ + +import { + createPublicClient, + createWalletClient, + http, + type Address, + type Hex, + type PublicClient, + type WalletClient, +} from 'viem'; +import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts'; +import { baseSepolia, base } from 'viem/chains'; + +import { + BASE_MAINNET_CHAIN_ID, + BASE_MAINNET_DEFAULT_RPC, + BASE_SEPOLIA_CHAIN_ID, + BASE_SEPOLIA_DEFAULT_RPC, + IDENTITY_REGISTRY_BASE_SEPOLIA, +} from './constants.js'; +import { IDENTITY_REGISTRY_ABI } from './abi.js'; +import { + AgentNotFoundError, + DryRunBlocked, + MainnetGuardError, + MissingSignerError, +} from './errors.js'; + +// Structural types — match @hyperdag/interfaces shape verbatim. Declared +// inline so this package does not depend on @hyperdag/interfaces being +// installed at build time (the published dist-only package lives in a +// sibling directory; resolving it through nx workspaces is a separate +// follow-up). The IIdentity interface is structural: anything implementing +// these methods will satisfy a consumer typed as `IIdentity`. +export type AgentId = bigint; +export interface RegistrationFileMetadata { + name?: string; + description?: string; + publicKey?: `0x${string}`; + attributes?: Record; +} +export interface RegistrationFile { + agentId: AgentId; + owner: Address; + metadataUri: string; + chainId: number; + metadata?: RegistrationFileMetadata; +} +export interface RegisterResult { + agentId: AgentId; + txHash?: `0x${string}`; + blockNumber?: number; +} +export interface TransferResult { + agentId: AgentId; + previousOwner: Address; + newOwner: Address; + txHash?: `0x${string}`; +} +export interface IIdentity { + register(m: RegistrationFileMetadata & { metadataUri: string }): Promise; + resolve(id: AgentId): Promise; + transfer(id: AgentId, newOwner: Address): Promise; +} + +export interface ViemIdentityProviderConfig { + /** Default 84532 (Base Sepolia). Pass 8453 for mainnet (with `allowMainnet: true`). */ + chainId?: number; + /** Default public Base Sepolia RPC. Override for higher rate limit or alt RPC. */ + rpcUrl?: string; + /** Override the IdentityRegistry address. Default = canonical Base Sepolia. */ + registryAddress?: Address; + /** Caller's private key for signed writes. Mutually exclusive with `walletClient`. */ + privateKey?: Hex; + /** Caller-supplied WalletClient (e.g. browser wallet, smart-account, etc.). */ + walletClient?: WalletClient; + /** Required `true` to target mainnet. Off-by-default safety guard. */ + allowMainnet?: boolean; + /** + * Default `true`. When true, writes throw `DryRunBlocked` instead of + * broadcasting. Set to `false` to actually fire transactions. + */ + dryRun?: boolean; +} + +export class ViemIdentityProvider implements IIdentity { + private readonly chainId: number; + private readonly registryAddress: Address; + private readonly publicClient: PublicClient; + private readonly walletClient?: WalletClient; + private readonly account?: PrivateKeyAccount; + private readonly dryRun: boolean; + + constructor(config: ViemIdentityProviderConfig = {}) { + this.chainId = config.chainId ?? BASE_SEPOLIA_CHAIN_ID; + + if (this.chainId === BASE_MAINNET_CHAIN_ID && !config.allowMainnet) { + // Guard surface only — throws on actual write attempts (in writeGate()). + // Constructor doesn't throw so consumers can read-only-probe mainnet + // without opting into allowMainnet. + } + + this.registryAddress = config.registryAddress ?? IDENTITY_REGISTRY_BASE_SEPOLIA; + const rpcUrl = + config.rpcUrl ?? + (this.chainId === BASE_MAINNET_CHAIN_ID + ? BASE_MAINNET_DEFAULT_RPC + : BASE_SEPOLIA_DEFAULT_RPC); + + const chain = this.chainId === BASE_MAINNET_CHAIN_ID ? base : baseSepolia; + + // viem's strict types want a tightly-typed Chain object; cast away the + // generic-instantiation noise here — runtime behavior is unaffected. + this.publicClient = createPublicClient({ + chain, + transport: http(rpcUrl), + }) as unknown as PublicClient; + + if (config.walletClient) { + this.walletClient = config.walletClient; + } else if (config.privateKey) { + this.account = privateKeyToAccount(config.privateKey); + this.walletClient = createWalletClient({ + account: this.account, + chain, + transport: http(rpcUrl), + }); + } + + // dryRun defaults TRUE — explicit opt-in to broadcast. + this.dryRun = config.dryRun ?? true; + } + + // ---------- Reads (zero-config) ---------- + + async resolve(agentId: AgentId): Promise { + let owner: Address; + try { + // Cast the call object to `any` to sidestep viem's overly-strict + // ReadContractParameters union — at runtime we're calling a + // well-formed view function; the union mismatch is purely a type-check + // artifact. + owner = (await (this.publicClient.readContract as any)({ + address: this.registryAddress, + abi: IDENTITY_REGISTRY_ABI, + functionName: 'ownerOf', + args: [agentId], + })) as Address; + } catch (e: any) { + // ERC-721 ownerOf reverts on non-existent token; surface a clear error. + if ( + typeof e?.message === 'string' && + (e.message.includes('ERC721NonexistentToken') || + e.message.includes('ERC721: invalid token') || + e.message.includes('execution reverted')) + ) { + throw new AgentNotFoundError(agentId); + } + throw e; + } + + let metadataUri = ''; + try { + metadataUri = (await (this.publicClient.readContract as any)({ + address: this.registryAddress, + abi: IDENTITY_REGISTRY_ABI, + functionName: 'tokenURI', + args: [agentId], + })) as string; + } catch { + // tokenURI revert is non-fatal — leave empty. + } + + return { + agentId, + owner, + metadataUri, + chainId: this.chainId, + }; + } + + /** Public read: agent's separately-managed wallet (ERC-8004 extension). */ + async getAgentWallet(agentId: AgentId): Promise
{ + return (await (this.publicClient.readContract as any)({ + address: this.registryAddress, + abi: IDENTITY_REGISTRY_ABI, + functionName: 'getAgentWallet', + args: [agentId], + })) as Address; + } + + /** Public read: arbitrary metadata key. */ + async getMetadata(agentId: AgentId, metadataKey: string): Promise { + return (await (this.publicClient.readContract as any)({ + address: this.registryAddress, + abi: IDENTITY_REGISTRY_ABI, + functionName: 'getMetadata', + args: [agentId, metadataKey], + })) as Hex; + } + + // ---------- Writes (gated) ---------- + + async register( + metadata: RegistrationFileMetadata & { metadataUri: string } + ): Promise { + const args = [metadata.metadataUri] as const; + const result = await this.writeGate('register', args); + return { + agentId: result.returnedAgentId ?? 0n, + txHash: result.txHash, + }; + } + + async transfer(agentId: AgentId, newOwner: Address): Promise { + const current = await this.resolve(agentId); + const args = [current.owner, newOwner, agentId] as const; + const result = await this.writeGate('transferFrom', args); + return { + agentId, + previousOwner: current.owner, + newOwner, + txHash: result.txHash, + }; + } + + /** Single point of broadcast-gate enforcement. */ + private async writeGate( + functionName: 'register' | 'transferFrom' | 'setAgentURI', + args: readonly unknown[] + ): Promise<{ txHash?: Hex; returnedAgentId?: bigint }> { + // Mainnet guard. + if (this.chainId === BASE_MAINNET_CHAIN_ID) { + throw new MainnetGuardError(this.chainId); + } + if (!this.walletClient || !this.account) { + throw new MissingSignerError(); + } + if (this.dryRun) { + throw new DryRunBlocked({ + address: this.registryAddress, + functionName, + args, + }); + } + // Real broadcast path. + const txHash = (await (this.walletClient as any).writeContract({ + address: this.registryAddress, + abi: IDENTITY_REGISTRY_ABI, + functionName, + args, + account: this.account, + chain: this.walletClient.chain, + })) as Hex; + return { txHash }; + } +} diff --git a/packages/defaults/identity-erc8004-viem/tests/live-read-direct.mjs b/packages/defaults/identity-erc8004-viem/tests/live-read-direct.mjs new file mode 100644 index 0000000..058c3ce --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/tests/live-read-direct.mjs @@ -0,0 +1,85 @@ +/** + * Live read smoke (direct viem, no TS compile) — proves the canonical Base + * Sepolia IdentityRegistry is reachable and the read pattern this package + * uses works against the live chain. + * + * Constants + ABI inlined here so the smoke can run without compiling .ts. + * The source-of-truth lives in src/constants.ts + src/abi.ts. + * + * Run: + * node tests/live-read-direct.mjs + */ + +import { createPublicClient, http } from 'viem'; +import { baseSepolia } from 'viem/chains'; + +const IDENTITY_REGISTRY_BASE_SEPOLIA = '0x8004A818BFB912233c491871b3d84c89A494BD9e'; +const BASE_SEPOLIA_DEFAULT_RPC = 'https://sepolia.base.org'; + +const IDENTITY_REGISTRY_ABI = [ + { name: 'ownerOf', type: 'function', stateMutability: 'view', + inputs: [{ name: 'tokenId', type: 'uint256' }], + outputs: [{ name: '', type: 'address' }] }, + { name: 'tokenURI', type: 'function', stateMutability: 'view', + inputs: [{ name: 'tokenId', type: 'uint256' }], + outputs: [{ name: '', type: 'string' }] }, + { name: 'getAgentWallet', type: 'function', stateMutability: 'view', + inputs: [{ name: 'agentId', type: 'uint256' }], + outputs: [{ name: '', type: 'address' }] }, +]; + +const client = createPublicClient({ + chain: baseSepolia, + transport: http(BASE_SEPOLIA_DEFAULT_RPC), +}); + +console.log('\n=== LIVE ERC-8004 READ SMOKE (Base Sepolia, no signer) ===\n'); +console.log(`registry = ${IDENTITY_REGISTRY_BASE_SEPOLIA}\n`); + +let pass = 0, fail = 0; +const tokensToProbe = [3747n, 1n, 2n, 100n]; + +for (const tokenId of tokensToProbe) { + try { + const owner = await client.readContract({ + address: IDENTITY_REGISTRY_BASE_SEPOLIA, + abi: IDENTITY_REGISTRY_ABI, + functionName: 'ownerOf', + args: [tokenId], + }); + let uri = ''; + try { + uri = await client.readContract({ + address: IDENTITY_REGISTRY_BASE_SEPOLIA, + abi: IDENTITY_REGISTRY_ABI, + functionName: 'tokenURI', + args: [tokenId], + }); + } catch (e) { + uri = `(tokenURI revert)`; + } + let wallet = ''; + try { + wallet = await client.readContract({ + address: IDENTITY_REGISTRY_BASE_SEPOLIA, + abi: IDENTITY_REGISTRY_ABI, + functionName: 'getAgentWallet', + args: [tokenId], + }); + } catch (e) { + wallet = '(getAgentWallet revert)'; + } + console.log(` agentId=${tokenId}: owner=${owner}`); + console.log(` uri=${uri}`); + console.log(` wallet=${wallet}`); + pass++; + } catch (e) { + const msg = e?.shortMessage ?? e?.message?.split('\n')[0] ?? String(e); + console.log(` agentId=${tokenId}: ${msg}`); + fail++; + } +} + +console.log(`\n=== READ RESULT: ${pass}/${tokensToProbe.length} succeeded (rest reverted / unknown tokenId — expected) ===`); +if (pass === 0) { console.error('FAIL — no successful reads.'); process.exit(1); } +console.log('=== smoke OK ==='); diff --git a/packages/defaults/identity-erc8004-viem/tests/live-read-smoke.mjs b/packages/defaults/identity-erc8004-viem/tests/live-read-smoke.mjs new file mode 100644 index 0000000..9645cc9 --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/tests/live-read-smoke.mjs @@ -0,0 +1,116 @@ +/** + * Live read smoke — proves the zero-config viem provider can read from the + * canonical ERC-8004 IdentityRegistry on Base Sepolia. No signer, no keys. + * + * Run: + * node tests/live-read-smoke.mjs + * + * Expected: reads ownerOf(3747) — trinity-sophia's canonical token id from + * CC1's memory entries — and prints owner + metadataUri. Then attempts a + * write in dryRun mode and confirms DryRunBlocked fires. + */ + +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pkgRoot = path.resolve(here, '..'); +const oneShotTs = path.join(pkgRoot, '.tsconfig.smoke.json'); + +fs.writeFileSync( + oneShotTs, + JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + outDir: '.smoke-dist', + rootDir: 'src', + strict: false, + skipLibCheck: true, + esModuleInterop: true, + declaration: false, + sourceMap: false, + }, + include: ['src/**/*'], + }, null, 2) +); + +const tsc = path.resolve(pkgRoot, '..', '..', '..', '..', 'repid-engine-cc1-mainnet', 'node_modules', '.bin', 'tsc.cmd'); +const tscResult = spawnSync(tsc, ['--project', '.tsconfig.smoke.json'], { cwd: pkgRoot, stdio: 'inherit', shell: true }); +if (tscResult.status !== 0) { + console.error('tsc failed; aborting smoke.'); + process.exit(2); +} + +// Add a node_modules path so viem resolves from hyperdag-protocol root install. +process.env.NODE_PATH = path.resolve(pkgRoot, '..', '..', '..', 'node_modules'); + +const distMain = path.join(pkgRoot, '.smoke-dist', 'index.js'); +const { ViemIdentityProvider, DryRunBlocked, MissingSignerError, IDENTITY_REGISTRY_BASE_SEPOLIA } = + await import('file:///' + distMain.replace(/\\/g, '/')); + +console.log('\n=== LIVE ERC-8004 READ SMOKE (Base Sepolia) ===\n'); +console.log(`registry = ${IDENTITY_REGISTRY_BASE_SEPOLIA}\n`); + +const provider = new ViemIdentityProvider({}); // zero-config, dryRun default true + +let readsPass = 0; +const tokensToProbe = [3747n, 1n, 2n, 100n]; + +for (const tokenId of tokensToProbe) { + try { + const file = await provider.resolve(tokenId); + console.log(`agentId=${tokenId}: owner=${file.owner} metadataUri=${file.metadataUri || '(empty)'} chainId=${file.chainId}`); + readsPass++; + } catch (e) { + console.log(`agentId=${tokenId}: ${e?.name ?? 'Error'} — ${e?.message?.split('\n')[0]}`); + } +} + +// Probe write paths +console.log('\n--- Write-path gating ---'); + +// 1. No signer + dryRun=true → MissingSignerError (checked BEFORE dryRun) +try { + await provider.register({ metadataUri: 'ipfs://test' }); + console.log('register (no signer): UNEXPECTED — should have thrown'); +} catch (e) { + console.log(`register (no signer): ${e?.name} ✓`); +} + +// 2. With dummy signer + dryRun=true → DryRunBlocked +const dummyKey = '0x0000000000000000000000000000000000000000000000000000000000000001'; +const guardedProvider = new ViemIdentityProvider({ privateKey: dummyKey }); +try { + await guardedProvider.register({ metadataUri: 'ipfs://test' }); + console.log('register (signer + dryRun): UNEXPECTED — should have thrown'); +} catch (e) { + if (e instanceof DryRunBlocked) { + console.log(`register (signer + dryRun): DryRunBlocked ✓ intended=${e.intended.functionName}(${e.intended.args[0]})`); + } else { + console.log(`register (signer + dryRun): ${e?.name ?? 'Error'} — ${e?.message?.split('\n')[0]}`); + } +} + +// 3. Mainnet write without allowMainnet → MainnetGuardError +const mainnetProvider = new ViemIdentityProvider({ chainId: 8453, privateKey: dummyKey, dryRun: false }); +try { + await mainnetProvider.register({ metadataUri: 'ipfs://test' }); + console.log('register (mainnet, no allowMainnet): UNEXPECTED — should have thrown'); +} catch (e) { + console.log(`register (mainnet, no allowMainnet): ${e?.name} ✓`); +} + +// Cleanup +try { fs.rmSync(path.join(pkgRoot, '.smoke-dist'), { recursive: true, force: true }); } catch {} +try { fs.unlinkSync(oneShotTs); } catch {} + +console.log(`\n=== READ RESULT: ${readsPass}/${tokensToProbe.length} reads succeeded ===`); +if (readsPass === 0) { + console.log('FAIL — no reads from live chain.'); + process.exit(1); +} +console.log('=== smoke OK ==='); diff --git a/packages/defaults/identity-erc8004-viem/tests/write-gating-smoke.mjs b/packages/defaults/identity-erc8004-viem/tests/write-gating-smoke.mjs new file mode 100644 index 0000000..871b77d --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/tests/write-gating-smoke.mjs @@ -0,0 +1,120 @@ +/** + * Write-gating smoke — proves the missing-signer / dryRun / mainnet-guard + * paths all fire correctly without any on-chain broadcast. + * + * Compiles src/ once to .smoke-dist/ (uses cc1-mainnet's tsc), then loads + * the compiled provider and exercises the three error paths. + */ + +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import fs from 'node:fs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const pkgRoot = path.resolve(here, '..'); + +// Cleanup helper +const ensureClean = (d) => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }; + +const smokeDir = path.join(pkgRoot, '.smoke-dist'); +const smokeCfg = path.join(pkgRoot, '.tsconfig.smoke.json'); +ensureClean(smokeDir); + +fs.writeFileSync(smokeCfg, JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'ESNext', + moduleResolution: 'Bundler', + outDir: '.smoke-dist', + rootDir: 'src', + strict: false, + skipLibCheck: true, + esModuleInterop: true, + declaration: false, + sourceMap: false, + }, + include: ['src/**/*'], +}, null, 2)); + +const tsc = path.resolve(pkgRoot, '..', '..', '..', '..', 'repid-engine-cc1-mainnet', 'node_modules', '.bin', 'tsc.cmd'); +const tscRes = spawnSync(tsc, ['--project', '.tsconfig.smoke.json'], { + cwd: pkgRoot, + stdio: 'inherit', + shell: true, + env: { ...process.env, NODE_PATH: path.resolve(pkgRoot, '..', '..', '..', 'node_modules') }, +}); +if (tscRes.status !== 0) { console.error('tsc failed'); process.exit(2); } + +// Add NODE_PATH so viem resolves +process.env.NODE_PATH = path.resolve(pkgRoot, '..', '..', '..', 'node_modules'); +const { Module } = await import('node:module'); +Module._initPaths(); + +const distMain = path.join(smokeDir, 'index.js'); +const mod = await import('file:///' + distMain.replace(/\\/g, '/')); +const { ViemIdentityProvider, DryRunBlocked, MissingSignerError, MainnetGuardError } = mod; + +console.log('\n=== WRITE-GATING SMOKE ===\n'); + +let pass = 0, fail = 0; +const must = (cond, label) => { + if (cond) { console.log(` ✓ ${label}`); pass++; } + else { console.log(` ✗ ${label}`); fail++; } +}; + +// 1. No signer → MissingSignerError on register/transfer +{ + const provider = new ViemIdentityProvider({}); // read-only + try { + await provider.register({ metadataUri: 'ipfs://x' }); + must(false, 'register without signer should throw'); + } catch (e) { + must(e instanceof MissingSignerError, 'register without signer → MissingSignerError'); + must( + typeof e.message === 'string' && e.message.includes('privateKey'), + 'error mentions privateKey in the one-line example' + ); + } +} + +// 2. Signer + dryRun=true (default) → DryRunBlocked with intended calldata captured +{ + const dummyKey = '0x' + '1'.repeat(64); + const provider = new ViemIdentityProvider({ privateKey: dummyKey }); + try { + await provider.register({ metadataUri: 'ipfs://test123' }); + must(false, 'register signer+dryRun should throw'); + } catch (e) { + must(e instanceof DryRunBlocked, 'register signer+dryRun → DryRunBlocked'); + must(e.intended?.functionName === 'register', 'intended functionName=register'); + must(e.intended?.args?.[0] === 'ipfs://test123', 'intended metadataUri argument captured'); + } +} + +// 3. Mainnet without allowMainnet → MainnetGuardError +{ + const dummyKey = '0x' + '1'.repeat(64); + const provider = new ViemIdentityProvider({ + chainId: 8453, + privateKey: dummyKey, + dryRun: false, // explicit broadcast intent + // allowMainnet omitted + }); + try { + await provider.register({ metadataUri: 'ipfs://mainnet' }); + must(false, 'mainnet write without allowMainnet should throw'); + } catch (e) { + must(e instanceof MainnetGuardError, 'mainnet write without allowMainnet → MainnetGuardError'); + } +} + +// 4. dryRun=false + signer + Base Sepolia → would broadcast, so we DON'T do it +// here. The gating layers above are what protect against unintended fire. +console.log(' (skipped — would broadcast: explicit Sean approval required)'); + +ensureClean(smokeDir); +try { fs.unlinkSync(smokeCfg); } catch {} + +console.log(`\n=== GATING RESULT: ${pass} pass / ${fail} fail ===`); +process.exit(fail === 0 ? 0 : 1); diff --git a/packages/defaults/identity-erc8004-viem/tsconfig.json b/packages/defaults/identity-erc8004-viem/tsconfig.json new file mode 100644 index 0000000..5400c57 --- /dev/null +++ b/packages/defaults/identity-erc8004-viem/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "tests"] +}