diff --git a/apps/api/drizzle/0061_retire_solutions.sql b/apps/api/drizzle/0061_retire_solutions.sql new file mode 100644 index 00000000..6e77bd86 --- /dev/null +++ b/apps/api/drizzle/0061_retire_solutions.sql @@ -0,0 +1,41 @@ +-- Retire solutions per DEC-20260503-A 2026-05-04. Public surface gone; +-- composition tech (lib/solution-executor.ts, gate4b-solution-dryrun.ts, +-- validateSolution) retained for any future bundled-product module. +-- +-- Strategy: archive both tables into _archived_2026_05_04 sibling tables +-- (preserves every column + adds archived_at), then truncate the live +-- tables. CASCADE on TRUNCATE solutions handles solution_steps via the +-- existing FK ON DELETE CASCADE, but archiving solution_steps separately +-- means the archive captures the full state independently of cascade +-- semantics. +-- +-- Schemas of the live `solutions` and `solution_steps` tables are RETAINED +-- because internal admin/debug routes (/v1/internal/trust/solutions/*, +-- /v1/internal/tests/solutions/*, /v1/internal/quality/solutions/*, +-- /v1/admin/create-solution, etc.) still reference them. Drop the schemas +-- in a future migration once those routes are also retired. +-- +-- Reversibility: +-- INSERT INTO solutions SELECT id, slug, name, description, long_description, +-- agent_description, category, price_cents, component_sum_cents, value_tier, +-- maintenance_level, geography, input_schema, example_input, example_output, +-- target_audience, marketing_name, transparency_tag, extends_with, +-- compliance_coverage, is_active, display_order, search_tags, x402_enabled, +-- created_at, updated_at FROM solutions_archived_2026_05_04; +-- INSERT INTO solution_steps SELECT id, solution_id, capability_slug, step_order, +-- can_parallel, parallel_group, input_map, created_at +-- FROM solution_steps_archived_2026_05_04; + +-- 1. Archive solutions rows (115 rows expected; pre-flight 2026-05-04). +CREATE TABLE IF NOT EXISTS solutions_archived_2026_05_04 AS + SELECT *, NOW() AS archived_at FROM solutions; + +-- 2. Archive solution_steps rows (892 rows expected; pre-flight 2026-05-04). +CREATE TABLE IF NOT EXISTS solution_steps_archived_2026_05_04 AS + SELECT *, NOW() AS archived_at FROM solution_steps; + +-- 3. Truncate live tables. CASCADE drops solution_steps rows via the +-- existing solution_steps.solution_id ON DELETE CASCADE FK, but listing +-- both explicitly keeps intent obvious. +TRUNCATE TABLE solutions CASCADE; +TRUNCATE TABLE solution_steps; diff --git a/apps/api/scripts/apply-migrations.ts b/apps/api/scripts/apply-migrations.ts index 518d62c8..4aca60bc 100644 --- a/apps/api/scripts/apply-migrations.ts +++ b/apps/api/scripts/apply-migrations.ts @@ -64,6 +64,28 @@ async function main() { console.log("[migration] actual_cost_cents already exists — skipping"); } + // Migration 0061: archive + truncate solutions + solution_steps per + // DEC-20260503-A. Idempotent because the archive tables use IF NOT + // EXISTS, the source tables are TRUNCATEd unconditionally each run, + // and a second run finds the live tables already empty (no-op). + // After archive tables exist on first run, future runs skip the + // CREATE TABLE AS step (no clobber) but still re-TRUNCATE — fine + // because the schema's intended steady state is empty live tables. + console.log("[migration] Ensuring solutions archive + truncate (DEC-20260503-A)..."); + await db.execute(sql` + CREATE TABLE IF NOT EXISTS solutions_archived_2026_05_04 AS + SELECT *, NOW() AS archived_at FROM solutions + `); + await db.execute(sql` + CREATE TABLE IF NOT EXISTS solution_steps_archived_2026_05_04 AS + SELECT *, NOW() AS archived_at FROM solution_steps + `); + // TRUNCATE both. Using two statements keeps intent explicit; CASCADE + // on solutions handles the FK to solution_steps either way. + await db.execute(sql`TRUNCATE TABLE solutions CASCADE`); + await db.execute(sql`TRUNCATE TABLE solution_steps`); + console.log("[migration] solutions retired (archived + truncated)"); + console.log("[migration] All migrations applied"); process.exit(0); } diff --git a/apps/api/scripts/console-allowlist.json b/apps/api/scripts/console-allowlist.json index c9f70be8..ed7cef5a 100644 --- a/apps/api/scripts/console-allowlist.json +++ b/apps/api/scripts/console-allowlist.json @@ -16,7 +16,6 @@ "apps/api/src/db/generate-tests.ts": 13, "apps/api/src/db/manual-test-rerun.ts": 14, "apps/api/src/db/seed-limitations.ts": 4, - "apps/api/src/db/seed-solutions.ts": 9, "apps/api/src/db/seed-tests.ts": 8, "apps/api/src/db/verify-dual-profile.ts": 15, "apps/api/src/diagnostics/self-heal-check.ts": 2, diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 450ecde6..9f5d8be9 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -20,13 +20,6 @@ import { agentCardRoute, a2aRoute } from "./routes/a2a.js"; import { adminRoute } from "./routes/admin.js"; import { solutionsRoute } from "./routes/solutions.js"; import { solutionExecuteRoute } from "./routes/solution-execute.js"; -import { web3AssuranceRoute } from "./web3-assurance/routes.js"; -import { - methodologyRoute as web3AssuranceMethodologyRoute, - sourceQualityRoute as web3AssuranceSourceQualityRoute, - bridgeConfigIndexRoute as web3AssuranceBridgeConfigIndexRoute, -} from "./web3-assurance/methodology.js"; -import { trustIndexRoute as web3AssuranceTrustIndexRoute } from "./web3-assurance/trust-index.js"; import { qualityRoute } from "./routes/quality.js"; import { suggestRoute } from "./routes/suggest.js"; import { internalQualityRoute } from "./routes/internal-quality.js"; @@ -281,13 +274,11 @@ app.post( ); app.route("/v1/demand-signals", demandSignalsRoute); app.route("/v1/admin", adminRoute); +// Solutions surface retired DEC-20260503-A 2026-05-04. Routes return 410 Gone; +// full handler removal is phase 1b. Web3 Assurance code deleted in lockstep +// (chat 2026-05-04) — its routes are unregistered entirely. app.route("/v1/solutions", solutionsRoute); app.route("/v1/solutions", solutionExecuteRoute); -app.route("/v1/web3-assurance", web3AssuranceRoute); -app.route("/v1/web3-assurance/methodology", web3AssuranceMethodologyRoute); -app.route("/v1/web3-assurance/source-quality", web3AssuranceSourceQualityRoute); -app.route("/v1/web3-assurance/bridge-config-index", web3AssuranceBridgeConfigIndexRoute); -app.route("/v1/web3-assurance/trust-index", web3AssuranceTrustIndexRoute); app.route("/v1/quality", qualityRoute); app.route("/v1", suggestRoute); // Single source of truth for facts that appear on multiple surfaces. diff --git a/apps/api/src/db/seed-solutions.ts b/apps/api/src/db/seed-solutions.ts deleted file mode 100644 index fa98ef9d..00000000 --- a/apps/api/src/db/seed-solutions.ts +++ /dev/null @@ -1,3076 +0,0 @@ -import { config } from "dotenv"; -import { resolve } from "node:path"; - -config({ path: resolve(import.meta.dirname, "../../../../.env") }); - -import { getDb } from "./index.js"; -import { capabilities, solutions, solutionSteps, type ComplianceCoverageItem } from "./schema.js"; -import { eq, inArray } from "drizzle-orm"; -import { validateSolution, enforceGates } from "../lib/onboarding-gates.js"; - -// ─── Solution definitions ─────────────────────────────────────────────────── - -interface SolutionStep { - capabilitySlug: string; - stepOrder: number; - canParallel: boolean; - parallelGroup: number | null; - inputMap: Record; -} - -interface SolutionDef { - slug: string; - name: string; - marketingName: string; - description: string; - longDescription?: string; - agentDescription?: string; - category: string; - priceCents: number; - componentSumCents: number; - valueTier: string; - maintenanceLevel: string; - geography: string; - targetAudience: string; - transparencyTag: string | null; - extendsWith: string[]; - inputSchema: Record; - exampleInput?: Record; - exampleOutput?: Record; - steps: SolutionStep[]; -} - -// ─── Compliance coverage builder ──────────────────────────────────────────── - -function buildComplianceCoverage(sol: SolutionDef): ComplianceCoverageItem[] { - const items: ComplianceCoverageItem[] = []; - const stepSlugs = sol.steps.map((s) => s.capabilitySlug); - const hasAI = sol.transparencyTag === "mixed" || sol.transparencyTag === "ai_generated"; - const isEU = ["eu", "eu-global", "nordic"].includes(sol.geography); - const isCompliance = ["compliance-verification", "security-risk", "legal-regulatory"].includes(sol.category); - - // Geography relevance: primary if scope matches solution geography, supporting otherwise - const geo = sol.geography; // e.g. "us", "us-global", "eu", "eu-global", "nordic", "global" - const geoHasUS = geo.includes("us"); - const geoHasEU = geo === "eu" || geo === "eu-global" || geo === "nordic"; - function relevance(scope: "eu" | "us" | "global"): "primary" | "supporting" { - if (scope === "global") return "primary"; - if (scope === "us" && geoHasUS) return "primary"; - if (scope === "eu" && geoHasEU) return "primary"; - return "supporting"; - } - - // Platform-level (all solutions) - items.push({ - framework: "Audit Trail", - reference: "Per-transaction record", - requirement: "Traceable execution records for every API call", - straleProvides: "Per-step timestamps, data sources, latency, schema validation, and input fingerprinting on every transaction", - scope: "global", - geographyRelevance: "primary", - }); - items.push({ - framework: "Audit Trail", - reference: "Trust Service Criteria CC7.2 (SOC 2)", - requirement: "System operations monitoring and anomaly detection", - straleProvides: "Continuous quality monitoring with automated health tracking — supports audit trail documentation for SOC 2 reviews", - scope: "global", - geographyRelevance: "primary", - }); - - // EU AI Act (if has AI involvement) - if (hasAI) { - const euRelevance = relevance("eu"); - items.push({ - framework: "EU AI Act", - reference: "Article 12", - requirement: "Record-keeping and automatic logging of AI system operations", - straleProvides: "Provides detailed execution logging with per-step timestamps, data sources, and latency metrics beyond typical API audit trails", - scope: "eu", - geographyRelevance: euRelevance, - }); - items.push({ - framework: "EU AI Act", - reference: "Article 13", - requirement: "Transparency — users must understand AI system output", - straleProvides: "Documents data source transparency and AI involvement level per step", - scope: "eu", - geographyRelevance: euRelevance, - }); - items.push({ - framework: "EU AI Act", - reference: "Article 14", - requirement: "Human oversight measures must be documented", - straleProvides: "Human oversight classification documented per step — demonstrates to regulators which steps involve AI decision-making and which are fully deterministic", - scope: "eu", - geographyRelevance: euRelevance, - }); - items.push({ - framework: "EU AI Act", - reference: "Article 50", - requirement: "AI-generated content must be marked", - straleProvides: "Logs AI involvement per step (LLM, algorithmic, or none)", - scope: "eu", - geographyRelevance: euRelevance, - }); - } - - // GDPR (EU-primary solutions get primary relevance; us-global/global get supporting) - if (isEU || geo === "us-global" || geo === "global") { - const gdprRelevance = isEU ? "primary" : "supporting" as const; - items.push({ - framework: "GDPR", - reference: "Article 30", - requirement: "Record of processing activities with data classifications", - straleProvides: "Provides complete processing record with per-step data classifications and source documentation", - scope: "eu", - geographyRelevance: gdprRelevance, - }); - items.push({ - framework: "GDPR", - reference: "Articles 15/17", - requirement: "Data subject access and right to erasure", - straleProvides: "Transaction data accessible via API and deletable via DELETE endpoint", - scope: "eu", - geographyRelevance: gdprRelevance, - }); - } - - // Sanctions screening (if includes sanctions-check) - if (stepSlugs.includes("sanctions-check")) { - items.push({ - framework: "Sanctions Screening", - reference: "31 CFR Part 501", - requirement: "Screening against OFAC SDN and consolidated sanctions lists", - straleProvides: "Automated screening against OFAC SDN, EU consolidated, and UN sanctions databases on every execution", - scope: "us", - geographyRelevance: relevance("us"), - }); - } - - // Regulatory data (if includes us-company-data) - if (stepSlugs.includes("us-company-data")) { - items.push({ - framework: "Regulatory Data", - reference: "Securities Exchange Act", - requirement: "Use of authoritative regulatory data for due diligence", - straleProvides: "Company data sourced from SEC EDGAR — official regulatory filings, not scraped third-party data", - scope: "us", - geographyRelevance: relevance("us"), - }); - } - - // Vendor due diligence (if compliance/security category) - if (isCompliance) { - items.push({ - framework: "Vendor Due Diligence", - reference: "Internal controls", - requirement: "Documented vendor assessment with traceable data sources", - straleProvides: "Every data point traced to its authoritative source with classification, timestamp, and AI involvement level", - scope: "global", - geographyRelevance: "primary", - }); - } - - return items; -} - -const SOLUTIONS: SolutionDef[] = [ - // ── 1. Nordic KYC — Sweden ── - { - slug: "kyc-sweden", - name: "Nordic KYC — Sweden", - marketingName: "Nordic KYC — Sweden", - description: - "Check if a Swedish company is real and safe to do business with. Official registry data, VAT validation, sanctions screening, PEP check, and adverse media scan in one call.", - longDescription: - "Looks up the company in Sweden's official business registry (Bolagsverket), validates their EU VAT number against VIES, and screens the company name against OFAC, EU, and UN sanctions lists. Use this before onboarding a customer, signing a contract, or generating a partnership agreement involving a Swedish entity.", - agentDescription: - "verify swedish company, is this swedish company legit, KYC check sweden, check swedish org number, onboard swedish business customer, validate swedish organization, bolagsverket lookup", - category: "compliance-verification", - priceCents: 150, - componentSumCents: 145, - valueTier: "verification", - maintenanceLevel: "very-low", - geography: "nordic", - targetAudience: "Developers building fintech tools, onboarding flows, or any agent that verifies Nordic businesses", - transparencyTag: "mixed", - extendsWith: ["company-enrich"], - inputSchema: { - type: "object", - properties: { - org_number: { - type: "string", - description: "Swedish organization number", - }, - }, - required: ["org_number"], - }, - exampleInput: { org_number: "556703-7485" }, - exampleOutput: { - company_name: "Spotify AB", - org_number: "556703-7485", - vat_valid: true, - vat_number: "SE556703748501", - is_sanctioned: false, - match_count: 0, - is_pep: false, - pep_match_count: 0, - adverse_media_risk: "none", - }, - steps: [ - { - capabilitySlug: "swedish-company-data", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { org_number: "$input.org_number" }, - }, - { - capabilitySlug: "vat-validate", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { vat_number: "$steps[0].vat_number" }, - }, - { - capabilitySlug: "sanctions-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "pep-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "adverse-media-check", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - ], - }, - - // ── 2. Nordic KYC — Norway ── - { - slug: "kyc-norway", - name: "Nordic KYC — Norway", - marketingName: "Nordic KYC — Norway", - description: - "Check if a Norwegian company is real and safe to do business with. Official registry data, VAT validation, sanctions screening, PEP check, and adverse media scan in one call.", - longDescription: - "Looks up the company in Norway's Brønnøysund Register Centre, validates their VAT number, and screens against international sanctions lists. Use this before doing business with a Norwegian entity.", - agentDescription: - "verify norwegian company, is this norwegian company legit, KYC check norway, check norwegian org number, brønnøysund lookup, validate norwegian organization", - category: "compliance-verification", - priceCents: 150, - componentSumCents: 145, - valueTier: "verification", - maintenanceLevel: "very-low", - geography: "nordic", - targetAudience: "Developers building fintech tools or agents that verify Norwegian businesses", - transparencyTag: "mixed", - extendsWith: ["domain-reputation", "company-enrich"], - inputSchema: { - type: "object", - properties: { - org_number: { - type: "string", - description: "Norwegian organization number", - }, - }, - required: ["org_number"], - }, - exampleInput: { org_number: "923609016" }, - exampleOutput: { - company_name: "EQUINOR ASA", - org_number: "923609016", - vat_valid: true, - vat_number: "NO923609016MVA", - is_sanctioned: false, - match_count: 0, - is_pep: false, - pep_match_count: 0, - adverse_media_risk: "none", - }, - steps: [ - { - capabilitySlug: "norwegian-company-data", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { org_number: "$input.org_number" }, - }, - { - capabilitySlug: "vat-validate", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { vat_number: "$steps[0].vat_number" }, - }, - { - capabilitySlug: "sanctions-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "pep-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "adverse-media-check", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - ], - }, - - // ── 3. Nordic KYC — Denmark ── - { - slug: "kyc-denmark", - name: "Nordic KYC — Denmark", - marketingName: "Nordic KYC — Denmark", - description: - "Check if a Danish company is real and safe to do business with. Official registry data, VAT validation, sanctions screening, PEP check, and adverse media scan in one call.", - longDescription: - "Looks up the company in Denmark's CVR register, validates their EU VAT number, and screens against international sanctions lists. Use this before doing business with a Danish entity.", - agentDescription: - "verify danish company, is this danish company legit, KYC check denmark, check danish CVR number, validate danish organization", - category: "compliance-verification", - priceCents: 150, - componentSumCents: 145, - valueTier: "verification", - maintenanceLevel: "very-low", - geography: "nordic", - targetAudience: "Developers building fintech tools or agents that verify Danish businesses", - transparencyTag: "mixed", - extendsWith: ["domain-reputation", "company-enrich"], - inputSchema: { - type: "object", - properties: { - cvr_number: { type: "string", description: "Danish CVR number" }, - }, - required: ["cvr_number"], - }, - exampleInput: { cvr_number: "47458714" }, - exampleOutput: { - company_name: "LEGO System A/S", - cvr_number: "47458714", - vat_valid: true, - vat_number: "DK47458714", - is_sanctioned: false, - match_count: 0, - is_pep: false, - pep_match_count: 0, - adverse_media_risk: "none", - }, - steps: [ - { - capabilitySlug: "danish-company-data", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { cvr_number: "$input.cvr_number" }, - }, - { - capabilitySlug: "vat-validate", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { vat_number: "$steps[0].vat_number" }, - }, - { - capabilitySlug: "sanctions-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "pep-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "adverse-media-check", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - ], - }, - - // ── 4. Nordic KYC — Finland ── - { - slug: "kyc-finland", - name: "Nordic KYC — Finland", - marketingName: "Nordic KYC — Finland", - description: - "Check if a Finnish company is real and safe to do business with. Official registry data, VAT validation, sanctions screening, PEP check, and adverse media scan in one call.", - longDescription: - "Looks up the company in Finland's PRH business register using their Y-tunnus (business ID), validates their EU VAT number, and screens against international sanctions lists. Use this before doing business with a Finnish entity.", - agentDescription: - "verify finnish company, is this finnish company legit, KYC check finland, check finnish business id, Y-tunnus lookup, validate finnish organization", - category: "compliance-verification", - priceCents: 150, - componentSumCents: 145, - valueTier: "verification", - maintenanceLevel: "very-low", - geography: "nordic", - targetAudience: "Developers building fintech tools or agents that verify Finnish businesses", - transparencyTag: "mixed", - extendsWith: ["domain-reputation", "company-enrich"], - inputSchema: { - type: "object", - properties: { - business_id: { - type: "string", - description: "Finnish business ID (Y-tunnus)", - }, - }, - required: ["business_id"], - }, - exampleInput: { business_id: "0112038-9" }, - exampleOutput: { - company_name: "Nokia Oyj", - business_id: "0112038-9", - vat_valid: true, - vat_number: "FI01120389", - is_sanctioned: false, - match_count: 0, - is_pep: false, - pep_match_count: 0, - adverse_media_risk: "none", - }, - steps: [ - { - capabilitySlug: "finnish-company-data", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { business_id: "$input.business_id" }, - }, - { - capabilitySlug: "vat-validate", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { vat_number: "$steps[0].vat_number" }, - }, - { - capabilitySlug: "sanctions-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "pep-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "adverse-media-check", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - ], - }, - - // ── 5. B2B Payment Validation ── - { - slug: "payment-validate", - name: "B2B Payment Validation", - marketingName: "B2B Payment Validation", - description: - "Verify bank details and tax IDs before your agent processes a payment. IBAN validation, bank identification, and counterparty VAT check.", - longDescription: - "Validates the IBAN structure and identifies the receiving bank, then confirms the counterparty's VAT registration is active. Catches invalid bank details and unregistered VAT numbers before money moves. Use this in any payment flow, invoice processing, or vendor onboarding.", - agentDescription: - "validate IBAN before payment, check bank details, verify payment recipient, is this IBAN real, validate counterparty before transfer, B2B payment check", - category: "finance-banking", - priceCents: 25, - componentSumCents: 15, - valueTier: "verification", - maintenanceLevel: "near-zero", - geography: "eu", - targetAudience: "Developers building payment flows or B2B finance tools", - transparencyTag: "algorithmic", - extendsWith: ["exchange-rate", "sanctions-check", "company-enrich"], - inputSchema: { - type: "object", - properties: { - iban: { type: "string" }, - vat_number: { type: "string" }, - }, - required: ["iban", "vat_number"], - }, - exampleInput: { - iban: "DE89370400440532013000", - vat_number: "DE136695976", - }, - exampleOutput: { - iban_valid: true, - country_code: "DE", - bank_code: "37040044", - vat_valid: true, - vat_company_name: "SAP SE", - }, - steps: [ - { - capabilitySlug: "iban-validate", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { iban: "$input.iban" }, - }, - { - capabilitySlug: "vat-validate", - stepOrder: 2, - canParallel: false, - parallelGroup: null, - inputMap: { vat_number: "$input.vat_number" }, - }, - ], - }, - - // ── 6. SEPA Payment Readiness ── - { - slug: "sepa-readiness", - name: "SEPA Payment Readiness", - marketingName: "SEPA Payment Readiness Check", - description: - "Everything your agent needs before initiating a SEPA transfer: IBAN validation, bank identification, VAT verification, and current exchange rate.", - longDescription: - "Validates the receiving IBAN, identifies the bank, confirms the counterparty's VAT registration, and fetches the current exchange rate if the source currency isn't EUR. One call replaces four separate API integrations. Use before any European bank transfer.", - agentDescription: - "prepare SEPA payment, SEPA transfer readiness check, European bank transfer validation, verify before SEPA transfer, IBAN and VAT for SEPA", - category: "finance-banking", - priceCents: 30, - componentSumCents: 17, - valueTier: "data-lookup", - maintenanceLevel: "near-zero", - geography: "eu", - targetAudience: "Developers building European payment flows or SEPA integrations", - transparencyTag: "algorithmic", - extendsWith: ["sanctions-check", "bank-bic-lookup", "company-enrich"], - inputSchema: { - type: "object", - properties: { - iban: { type: "string" }, - vat_number: { type: "string" }, - source_currency: { - type: "string", - description: "ISO 4217 currency code, omit if EUR", - }, - }, - required: ["iban", "vat_number"], - }, - exampleInput: { - iban: "DE89370400440532013000", - vat_number: "DE136695976", - source_currency: "SEK", - }, - exampleOutput: { - iban_valid: true, - country_code: "DE", - bank_code: "37040044", - vat_valid: true, - vat_company_name: "SAP SE", - exchange_rate: 0.0936, - exchange_rate_date: "2026-03-05", - }, - steps: [ - { - capabilitySlug: "iban-validate", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { iban: "$input.iban" }, - }, - { - capabilitySlug: "vat-validate", - stepOrder: 2, - canParallel: false, - parallelGroup: null, - inputMap: { vat_number: "$input.vat_number" }, - }, - { - capabilitySlug: "exchange-rate", - stepOrder: 3, - canParallel: false, - parallelGroup: null, - inputMap: { from: "$input.source_currency", to: "EUR" }, - }, - ], - }, - - // ── 7. Lead Email Verify ── - { - slug: "lead-email-verify", - name: "Lead Email Verify", - marketingName: "Lead Email Verification", - description: - "Your agent found a prospect's email — is it real? Deliverability check, DNS, and domain reputation in one call.", - longDescription: - "Validates the email address for deliverability, checks the domain's DNS configuration and MX records, and scores the domain's reputation. Catches disposable addresses, invalid mailboxes, and suspicious domains before your agent sends outreach. Use in any sales, marketing, or CRM enrichment pipeline.", - agentDescription: - "is this email real, check email before sending, validate prospect email, email deliverability check, verify email address, is this a real mailbox", - category: "sales-outreach", - priceCents: 20, - componentSumCents: 11, - valueTier: "data-lookup", - maintenanceLevel: "near-zero", - geography: "us-global", - targetAudience: - "Developers building outbound sales agents, email verification, or lead gen pipelines", - transparencyTag: "algorithmic", - extendsWith: ["company-enrich", "tech-stack-detect", "whois-lookup"], - inputSchema: { - type: "object", - properties: { email: { type: "string", format: "email" } }, - required: ["email"], - }, - exampleInput: { email: "test@google.com" }, - exampleOutput: { - valid: true, - domain: "google.com", - has_mx: true, - is_disposable: false, - reputation_score: 92, - }, - steps: [ - { - capabilitySlug: "email-validate", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { email: "$input.email" }, - }, - { - capabilitySlug: "dns-lookup", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$steps[0].domain" }, - }, - { - capabilitySlug: "domain-reputation", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$steps[0].domain" }, - }, - ], - }, - - // ── 8. US Company Verify ── - { - slug: "verify-us-company", - name: "US Company Verify", - marketingName: "US Company Verification", - description: - "Look up any US company using SEC EDGAR data, screen against international sanctions lists, check for politically exposed persons (PEP), and scan for adverse media coverage. Filing data, company status, sanctions, PEP, and media check.", - longDescription: - "Queries SEC EDGAR for official filing data using a company name, ticker symbol, or CIK number, then screens the company against OFAC, EU, and UN sanctions lists. Returns company name, CIK, state of incorporation, and sanctions status. Use before doing business with a US entity.", - agentDescription: - "verify US company, check if american company exists, SEC company lookup, is this US company legit, US company sanctions check, EDGAR lookup", - category: "compliance-verification", - priceCents: 130, - componentSumCents: 135, - valueTier: "verification", - maintenanceLevel: "very-low", - geography: "us", - targetAudience: - "Developers doing vendor/partner onboarding or compliance checks on US companies", - transparencyTag: "ai_generated", - extendsWith: ["domain-reputation", "tech-stack-detect", "company-enrich"], - inputSchema: { - type: "object", - properties: { - company: { - type: "string", - description: "CIK number, ticker symbol, or company name", - }, - }, - required: ["company"], - }, - exampleInput: { company: "AAPL" }, - exampleOutput: { - company_name: "Apple Inc.", - cik: "0000320193", - state: "CA", - is_sanctioned: false, - match_count: 0, - is_pep: false, - pep_match_count: 0, - adverse_media_risk: "none", - }, - steps: [ - { - capabilitySlug: "us-company-data", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { company: "$input.company" }, - }, - { - capabilitySlug: "sanctions-check", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "pep-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "adverse-media-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].company_name" }, - }, - ], - }, - - // ── 9. Domain Intelligence ── - { - slug: "domain-intel", - name: "Domain Intelligence", - marketingName: "Domain Intelligence Report", - description: - "Everything your agent needs to evaluate a domain: registration details, DNS setup, SSL health, and reputation score.", - longDescription: - "Combines WHOIS registration data, DNS record analysis, SSL certificate validation, and domain reputation scoring into one report. Use when your agent is researching a company, vetting a vendor, qualifying a lead, or checking a link before following it.", - agentDescription: - "look up this domain, who owns this website, is this domain trustworthy, domain background check, domain registration info, check website reputation", - category: "security-risk", - priceCents: 35, - componentSumCents: 21, - valueTier: "data-lookup", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: - "Any developer doing vendor assessment, lead qualification, or security monitoring", - transparencyTag: "algorithmic", - extendsWith: ["tech-stack-detect", "page-speed-test", "backlink-check"], - inputSchema: { - type: "object", - properties: { domain: { type: "string" } }, - required: ["domain"], - }, - exampleInput: { domain: "google.com" }, - exampleOutput: { - registrar: "MarkMonitor Inc.", - ssl_valid: true, - dns_records: true, - reputation_score: 95, - }, - steps: [ - { - capabilitySlug: "whois-lookup", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "dns-lookup", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "ssl-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "domain-reputation", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - ], - }, - - // ── 10. Web Extract & Clean ── - { - slug: "web-extract-clean", - name: "Web Extract & Clean", - marketingName: "Web Extract & Clean", - description: - "Fetch any web page — even behind anti-bot walls — convert to clean markdown, and strip PII automatically. Ready for your RAG pipeline.", - longDescription: - "Uses a headless browser to fetch JavaScript-rendered pages that block direct requests, converts the content to clean markdown, then automatically detects and redacts PII (names, emails, phone numbers, addresses) before returning. Ready to drop into your RAG pipeline, knowledge base, or agent context window.", - agentDescription: - "scrape this page, get content from URL, extract web page for RAG, page blocked need content, convert URL to markdown, fetch website content, web scraping with anti-bot", - category: "data-research", - priceCents: 30, - componentSumCents: 20, - valueTier: "data-lookup", - maintenanceLevel: "low", - geography: "global", - targetAudience: - "Any developer building research agents, RAG pipelines, or content ingestion", - transparencyTag: "mixed", - extendsWith: ["structured-scrape", "translate", "summarize"], - inputSchema: { - type: "object", - properties: { url: { type: "string", format: "uri" } }, - required: ["url"], - }, - exampleInput: { url: "https://example.com" }, - exampleOutput: { - markdown: "# Example Domain\n\nThis domain is for use in illustrative examples...", - pii_redacted: 0, - word_count: 58, - }, - steps: [ - { - capabilitySlug: "url-to-markdown", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { url: "$input.url" }, - }, - { - capabilitySlug: "pii-redact", - stepOrder: 2, - canParallel: false, - parallelGroup: null, - inputMap: { text: "$steps[0].markdown" }, - }, - ], - }, - - // ── 11. Email Deliverability Audit ── - { - slug: "email-audit", - name: "Email Deliverability Audit", - marketingName: "Email Deliverability Audit", - description: - "Will emails to this domain actually arrive? SPF, DKIM, DMARC, MX records, SSL, and blacklist status — the complete deliverability audit.", - longDescription: - "Checks every factor that determines inbox placement: SPF record configuration, DKIM signing, DMARC policy, MX record health, SSL certificate status, and blacklist presence. Use when evaluating email infrastructure for lead qualification, vendor assessment, or monitoring your own domain's deliverability.", - agentDescription: - "check email config for domain, will emails arrive at this domain, email infrastructure audit, SPF DKIM DMARC check, email deliverability test, can this domain receive email", - category: "security-risk", - priceCents: 25, - componentSumCents: 16, - valueTier: "verification", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: "Any developer or team sending email at scale", - transparencyTag: "algorithmic", - extendsWith: ["domain-reputation", "whois-lookup", "tech-stack-detect"], - inputSchema: { - type: "object", - properties: { domain: { type: "string" } }, - required: ["domain"], - }, - exampleInput: { domain: "google.com" }, - exampleOutput: { - score: 95, - grade: "A", - spf: "pass", - dmarc: "pass", - ssl_valid: true, - }, - steps: [ - { - capabilitySlug: "email-deliverability-check", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "dns-lookup", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "ssl-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - ], - }, - - // ── 12. Website Health Check ── - { - slug: "website-health", - name: "Website Health Check", - marketingName: "Website Health Check", - description: - "Quick technical health check on any website: SSL certificate chain, email deliverability configuration, and page load performance.", - longDescription: - "Validates the full SSL certificate chain, checks email deliverability configuration (SPF/DKIM/DMARC), and measures page load performance. Your DevOps agent's first check when monitoring a service, or a quick sanity check before your agent interacts with an external site.", - agentDescription: - "is this website healthy, check website performance, site health check, SSL and speed test, website technical audit, is this site working properly", - category: "security-risk", - priceCents: 40, - componentSumCents: 25, - valueTier: "data-lookup", - maintenanceLevel: "low", - geography: "global", - targetAudience: "DevOps teams, B2B SaaS, or agents monitoring external services", - transparencyTag: "algorithmic", - extendsWith: ["dns-lookup", "whois-lookup", "domain-reputation"], - inputSchema: { - type: "object", - properties: { domain: { type: "string" } }, - required: ["domain"], - }, - exampleInput: { domain: "google.com" }, - exampleOutput: { - certificate_chain: [{ subject: "*.google.com", issuer: "GTS CA 1C3" }], - email_score: 95, - performance_score: 92, - }, - steps: [ - { - capabilitySlug: "ssl-certificate-chain", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "email-deliverability-check", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "page-speed-test", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.domain" }, - }, - ], - }, - - // ── 13. EU AI Act Risk Assessment ── - { - slug: "ai-act-assess", - name: "EU AI Act Risk Assessment", - marketingName: "EU AI Act Risk Assessment", - description: - "Classify your AI system's risk level under the EU AI Act before the August 2026 deadline. Risk classification, obligations, and supervisory authority.", - longDescription: - "Analyzes your AI system description against EU AI Act risk categories, identifies matched articles and specific obligations, looks up the relevant national supervisory authority, and checks for prior enforcement actions in your sector. Use before deploying any AI system in the EU.", - agentDescription: - "EU AI Act classification, is my AI system high risk, AI compliance check, what are my AI Act obligations, EU AI regulation check, AI risk assessment Europe", - category: "legal-regulatory", - priceCents: 80, - componentSumCents: 45, - valueTier: "compliance", - maintenanceLevel: "low", - geography: "eu", - targetAudience: - "Any developer deploying AI systems in the EU. August 2026 enforcement deadline.", - transparencyTag: "mixed", - extendsWith: ["gdpr-website-check", "privacy-policy-analyze", "cookie-scan"], - inputSchema: { - type: "object", - properties: { - description: { - type: "string", - description: "Describe what your AI system does", - }, - deployment_country: { - type: "string", - description: "ISO 3166-1 alpha-2 country code", - }, - company: { type: "string" }, - }, - required: ["description", "deployment_country"], - }, - exampleInput: { - description: - "AI system that triages patient symptoms and recommends urgency level for GP appointments", - deployment_country: "SE", - company: "Kry", - }, - exampleOutput: { - risk_level: "high", - category: "Access to essential services", - obligations: [ - "Conformity assessment", - "Risk management system", - "Data governance", - ], - supervisory_authority: { name: "IMY", country: "SE" }, - prior_enforcement: [], - }, - steps: [ - { - capabilitySlug: "eu-ai-act-classify", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { - description: "$input.description", - }, - }, - { - capabilitySlug: "data-protection-authority-lookup", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { country_code: "$input.deployment_country" }, - }, - { - capabilitySlug: "gdpr-fine-lookup", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { company: "$input.company" }, - }, - ], - }, - - // ── 14. Website GDPR Audit ── - { - slug: "gdpr-audit", - name: "Website GDPR Audit", - marketingName: "Website GDPR Compliance Audit", - description: - "Is this website GDPR compliant? Scans cookies, consent mechanisms, privacy policy, SSL, and identifies the relevant data protection authority.", - longDescription: - "Comprehensive GDPR assessment: scans for cookie consent implementation, analyzes tracking scripts, evaluates privacy policy quality, checks SSL security, and identifies the responsible data protection authority for the website's jurisdiction. Use before your agent ships a feature, evaluates a vendor, or monitors compliance across a portfolio.", - agentDescription: - "GDPR check this website, is this site GDPR compliant, cookie and privacy audit, check website privacy compliance, European data protection check, does this website follow GDPR", - category: "legal-regulatory", - priceCents: 100, - componentSumCents: 63, - valueTier: "compliance", - maintenanceLevel: "low-medium", - geography: "eu-global", - targetAudience: - "Any developer assessing vendor/partner compliance or monitoring GDPR across a portfolio", - transparencyTag: "mixed", - extendsWith: ["tech-stack-detect", "domain-reputation", "dns-lookup"], - inputSchema: { - type: "object", - properties: { - url: { type: "string", format: "uri" }, - country_code: { - type: "string", - description: "ISO 3166-1 alpha-2 (for DPA lookup)", - }, - }, - required: ["url"], - }, - exampleInput: { url: "https://google.com", country_code: "SE" }, - exampleOutput: { - gdpr_score: 72, - grade: "B", - has_cookie_consent: true, - tracking_scripts: { google_analytics: true }, - privacy_policy: { data_retention_mentioned: true, dpo_listed: false }, - ssl_valid: true, - supervisory_authority: { name: "IMY" }, - }, - steps: [ - { - capabilitySlug: "gdpr-website-check", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.url" }, - }, - { - capabilitySlug: "cookie-scan", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.url" }, - }, - { - capabilitySlug: "privacy-policy-analyze", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.url" }, - }, - { - capabilitySlug: "ssl-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.url" }, - }, - { - capabilitySlug: "data-protection-authority-lookup", - stepOrder: 5, - canParallel: true, - parallelGroup: 2, - inputMap: { country_code: "$input.country_code" }, - }, - ], - }, - - // ── 15. Competitive Intelligence Snapshot ── - { - slug: "competitor-snapshot", - name: "Competitive Intelligence Snapshot", - marketingName: "Competitive Intelligence Snapshot", - description: - "Tech stack, SEO, landing page analysis, social presence — the competitive read your agent can run in seconds.", - longDescription: - "Detects the competitor's technology stack (frameworks, hosting, analytics), audits their SEO health (meta tags, structure, performance), analyzes landing page conversion elements, and checks social media presence. Use for market research, fundraising prep, GTM positioning, or any agent building competitive intelligence.", - agentDescription: - "research this competitor, competitive analysis, what tech does this company use, analyze competitor website, competitor intelligence, market research on company", - category: "data-research", - priceCents: 140, - componentSumCents: 110, - valueTier: "compliance", - maintenanceLevel: "low", - geography: "global", - targetAudience: - "Any developer building competitive intelligence, market research, or fundraising prep tools", - transparencyTag: "mixed", - extendsWith: ["page-speed-test", "backlink-check", "domain-reputation"], - inputSchema: { - type: "object", - properties: { - competitor_url: { type: "string", format: "uri" }, - brand_username: { - type: "string", - description: "Social media username (optional)", - }, - }, - required: ["competitor_url"], - }, - exampleInput: { - competitor_url: "https://stripe.com", - brand_username: "stripe", - }, - exampleOutput: { - tech_stack: { frontend: "React", hosting: "AWS" }, - seo: { overall_score: 82, issues: [] }, - landing_page: { overall_score: 78, strengths: [], weaknesses: [] }, - social: { github: true, twitter: true, linkedin: true }, - }, - steps: [ - { - capabilitySlug: "tech-stack-detect", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.competitor_url" }, - }, - { - capabilitySlug: "seo-audit", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.competitor_url" }, - }, - { - capabilitySlug: "landing-page-roast", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.competitor_url" }, - }, - { - capabilitySlug: "social-profile-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 2, - inputMap: { username: "$input.brand_username" }, - }, - ], - }, - // ── 16. Vendor Risk Assessment ── - { - slug: "vendor-risk-assess", - name: "Vendor Risk Assessment", - marketingName: "Vendor Risk Assessment", - description: - "SEC data, sanctions screening, domain security — answers 'should we do business with this company?' in one call.", - longDescription: - "Checks a company across 7 data sources in one call: SEC EDGAR, OFAC/EU/UN sanctions, WHOIS, DNS, SSL, domain reputation, and HTTP security headers. All steps run in parallel, quality-verified before delivery. Use before onboarding a vendor, signing a partnership, or approving a procurement request.", - agentDescription: - "assess vendor risk, should we do business with this company, vendor due diligence, vendor security check, is this vendor safe, company risk assessment, supplier evaluation, partnership due diligence", - category: "security-risk", - priceCents: 180, - componentSumCents: 129, - valueTier: "verification", - maintenanceLevel: "very-low", - geography: "us-global", - targetAudience: - "Developers building procurement agents, vendor management, or partnership due diligence tools", - transparencyTag: "mixed", - extendsWith: ["seo-audit", "company-enrich", "tech-stack-detect"], - inputSchema: { - type: "object", - properties: { - company: { - type: "string", - description: "Company name, CIK, or ticker", - }, - domain: { - type: "string", - description: "Company domain to assess", - }, - }, - required: ["company", "domain"], - }, - exampleInput: { company: "Apple Inc", domain: "apple.com" }, - exampleOutput: { - company_name: "Apple Inc.", - cik: "0000320193", - is_sanctioned: false, - domain_reputation_score: 95, - ssl_valid: true, - header_security_grade: "A", - }, - steps: [ - { - capabilitySlug: "us-company-data", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { company: "$input.company" }, - }, - { - capabilitySlug: "sanctions-check", - stepOrder: 2, - canParallel: true, - parallelGroup: 2, - inputMap: { name: "$steps[0].company_name" }, - }, - { - capabilitySlug: "whois-lookup", - stepOrder: 3, - canParallel: true, - parallelGroup: 2, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "dns-lookup", - stepOrder: 4, - canParallel: true, - parallelGroup: 2, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "ssl-check", - stepOrder: 5, - canParallel: true, - parallelGroup: 2, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "domain-reputation", - stepOrder: 6, - canParallel: true, - parallelGroup: 2, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "header-security-check", - stepOrder: 7, - canParallel: true, - parallelGroup: 2, - inputMap: { url: "$input.domain" }, - }, - ], - }, - - // ── 17. Lead Enrichment & Qualification ── - { - slug: "lead-enrich", - name: "Lead Enrichment & Qualification", - marketingName: "Lead Enrichment & Qualification", - description: - "Everything your sales agent needs from one email address — validation, domain intel, reputation, registrar, and tech stack detection.", - longDescription: - "Starting from just an email address: validates deliverability, checks DNS records, scores domain reputation, looks up WHOIS registration data, and detects the company's technology stack. Returns everything an outbound agent needs to qualify a prospect and personalize outreach. Use in CRM enrichment, SDR automation, or account research pipelines.", - agentDescription: - "enrich this lead, prospect research from email, lead qualification, what company is this email from, CRM enrichment, sales prospecting data, who is this prospect", - category: "sales-outreach", - priceCents: 65, - componentSumCents: 46, - valueTier: "verification", - maintenanceLevel: "low", - geography: "us-global", - targetAudience: - "Developers building outbound sales agents, CRM enrichment, or account research tools", - transparencyTag: "mixed", - extendsWith: ["company-enrich", "seo-audit", "landing-page-roast"], - inputSchema: { - type: "object", - properties: { - email: { - type: "string", - format: "email", - description: "Prospect email address", - }, - }, - required: ["email"], - }, - exampleInput: { email: "jane@acme.com" }, - exampleOutput: { - valid: true, - domain: "acme.com", - has_mx: true, - reputation_score: 78, - registrar: "GoDaddy", - tech_stack: { frontend: "React", cms: "WordPress" }, - }, - steps: [ - { - capabilitySlug: "email-validate", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { email: "$input.email" }, - }, - { - capabilitySlug: "dns-lookup", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$steps[0].domain" }, - }, - { - capabilitySlug: "domain-reputation", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$steps[0].domain" }, - }, - { - capabilitySlug: "whois-lookup", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$steps[0].domain" }, - }, - { - capabilitySlug: "tech-stack-detect", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$steps[0].domain" }, - }, - ], - }, - - // ── 18. Website Security Audit ── - { - slug: "website-security-audit", - name: "Website Security Audit", - marketingName: "Website Security Audit", - description: - "Is this website secure? SSL, HTTP headers, DNS, and tech stack — the security audit your agent runs before trusting any URL.", - longDescription: - "Comprehensive security posture assessment: validates SSL certificate health, audits HTTP security header configuration (CSP, HSTS, X-Frame-Options), checks DNS security, and maps the technology surface area. Use when your security agent evaluates a vendor, your procurement agent vets a SaaS tool, or your monitoring agent checks your own infrastructure.", - agentDescription: - "security audit this website, is this URL safe, check website security, SSL and header security check, website vulnerability assessment, is this site secure", - category: "security-risk", - priceCents: 45, - componentSumCents: 29, - valueTier: "verification", - maintenanceLevel: "low", - geography: "global", - targetAudience: - "Security teams, developers filling security questionnaires, or compliance monitoring agents", - transparencyTag: "mixed", - extendsWith: ["ssl-certificate-chain", "page-speed-test", "whois-lookup"], - inputSchema: { - type: "object", - properties: { - url: { - type: "string", - description: "URL to audit", - }, - }, - required: ["url"], - }, - exampleInput: { url: "https://example.com" }, - exampleOutput: { - ssl_valid: true, - header_security_grade: "B", - dns_records: true, - tech_stack: { frontend: "Vanilla", hosting: "AWS" }, - }, - steps: [ - { - capabilitySlug: "ssl-check", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.url" }, - }, - { - capabilitySlug: "header-security-check", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.url" }, - }, - { - capabilitySlug: "dns-lookup", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.url" }, - }, - { - capabilitySlug: "tech-stack-detect", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.url" }, - }, - ], - }, - - // ── 19. Prospect Company Profile ── - { - slug: "prospect-profile", - name: "Prospect Company Profile", - marketingName: "Prospect Company Profile", - description: - "Everything your sales agent researches before a call: SEC data, tech stack, web presence, and domain credibility — delivered in seconds.", - longDescription: - "Given a company name and URL: pulls SEC EDGAR filing data, detects their technology stack, audits their SEO and web presence strength, and scores their domain credibility. The complete pre-call research package that would take a human 30 minutes, delivered to your agent in seconds.", - agentDescription: - "research this prospect, company profile for sales call, what does this company do, pre-call research, prospect intelligence, account research before meeting", - category: "sales-outreach", - priceCents: 180, - componentSumCents: 140, - valueTier: "data-lookup", - maintenanceLevel: "low", - geography: "us-global", - targetAudience: - "Developers building account research, CRM enrichment, or pre-call intelligence tools", - transparencyTag: "mixed", - extendsWith: ["sanctions-check", "whois-lookup", "landing-page-roast"], - inputSchema: { - type: "object", - properties: { - company: { - type: "string", - description: "Company name, CIK, or ticker", - }, - url: { - type: "string", - description: "Company website URL", - }, - }, - required: ["company", "url"], - }, - exampleInput: { company: "Stripe", url: "https://stripe.com" }, - exampleOutput: { - company_name: "Stripe, Inc.", - cik: "0001779474", - tech_stack: { frontend: "React", hosting: "AWS" }, - seo_score: 88, - reputation_score: 95, - }, - steps: [ - { - capabilitySlug: "us-company-data", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { company: "$input.company" }, - }, - { - capabilitySlug: "tech-stack-detect", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.url" }, - }, - { - capabilitySlug: "seo-audit", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.url" }, - }, - { - capabilitySlug: "domain-reputation", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.url" }, - }, - ], - }, - - // ── 20b. Company Intelligence for SDR ── - { - slug: "company-intelligence-sdr", - name: "Company Intelligence for SDR", - marketingName: "Company Intelligence for SDR Agents", - description: - "Everything your SDR agent needs to decide whether to pursue a company: filing events, news sentiment, officers, tech stack, email patterns, social presence, domain age, and hiring signals. One call, 9 steps.", - longDescription: - "The complete company research package for AI SDR agents. Given a company name and domain, returns: recent filing events (SEC/Companies House), news sentiment from global media, company officers and directors from public records, technology stack detection, email pattern discovery, social media presence across 11 platforms, domain registration age and registrar (WHOIS), and hiring activity from job boards. Replaces the manual process of querying 9+ APIs separately. Every data point is quality-scored and sourced.", - agentDescription: - "research this company for outreach, SDR company intelligence, should I pursue this company, company research for sales, prospect company analysis, pre-outreach research, is this company worth targeting", - category: "sales-outreach", - priceCents: 250, - componentSumCents: 200, - valueTier: "verification", - maintenanceLevel: "low", - geography: "us-uk-eu", - targetAudience: - "Developers building AI SDR agents, outbound sales automation, or account research tools", - transparencyTag: "mixed", - extendsWith: ["sanctions-check", "vat-validate", "beneficial-ownership-lookup"], - inputSchema: { - type: "object", - properties: { - company_name: { - type: "string", - description: "Company name (e.g. Stripe, BMW, Novo Nordisk)", - }, - domain: { - type: "string", - description: "Company website domain (e.g. stripe.com)", - }, - country: { - type: "string", - description: "Country code (US, GB, DE, etc.) — improves accuracy for registry lookups", - }, - }, - required: ["company_name"], - }, - exampleInput: { company_name: "Stripe", domain: "stripe.com", country: "US" }, - exampleOutput: { - company_name: "Stripe, Inc.", - registry: { status: "active", cik: "0001779474" }, - filing_events: [{ date: "2026-03-15", type: "financial_results" }], - news: { articles: 5, sentiment: "positive" }, - tech_stack: ["Next.js", "Nginx", "Google Analytics"], - officers: [{ name: "John Collison", role: "President" }], - email_pattern: "first.last@stripe.com", - }, - steps: [ - // Parallel group 1: Company identity + filing events - { - capabilitySlug: "sec-filing-events", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { company_name: "$input.company_name" }, - }, - { - capabilitySlug: "company-news", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { company_name: "$input.company_name", timespan: "14d" }, - }, - { - capabilitySlug: "officer-search", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { company_name: "$input.company_name", country: "$input.country" }, - }, - // Parallel group 2: Domain-based signals (need domain input) - { - capabilitySlug: "tech-stack-detect", - stepOrder: 4, - canParallel: true, - parallelGroup: 2, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "email-pattern-discover", - stepOrder: 5, - canParallel: true, - parallelGroup: 2, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "domain-reputation", - stepOrder: 6, - canParallel: true, - parallelGroup: 2, - inputMap: { domain: "$input.domain" }, - }, - // Parallel group 3: Hiring + social + domain age - { - capabilitySlug: "job-board-search", - stepOrder: 7, - canParallel: true, - parallelGroup: 3, - inputMap: { query: "$input.company_name" }, - }, - { - capabilitySlug: "social-profile-check", - stepOrder: 8, - canParallel: true, - parallelGroup: 3, - inputMap: { username: "$input.company_name" }, - }, - { - capabilitySlug: "whois-lookup", - stepOrder: 9, - canParallel: true, - parallelGroup: 3, - inputMap: { domain: "$input.domain" }, - }, - ], - }, - - // ── 20. Domain Trust Check ── - { - slug: "domain-trust", - name: "Domain Trust Check", - marketingName: "Domain Trust Check", - description: - "Given a domain, answer 'is this domain trustworthy?' — registration age, DNS, SSL, reputation score, and HTTP security headers.", - longDescription: - "Checks five trust signals for any domain: WHOIS registration age and registrar, DNS configuration health, SSL certificate validity, reputation score from threat intelligence feeds, and HTTP security header grade. Use for phishing detection, link safety checks, brand protection, or any time your agent needs to evaluate whether a domain is trustworthy before interacting with it.", - agentDescription: - "is this domain trustworthy, check domain trust, domain reputation check, is this website safe to visit, phishing check, link safety verification, should I trust this domain", - category: "security-risk", - priceCents: 40, - componentSumCents: 29, - valueTier: "data-lookup", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: - "Anti-fraud teams, brand protection, phishing detection, or cybersecurity agents", - transparencyTag: "algorithmic", - extendsWith: ["tech-stack-detect", "backlink-check", "page-speed-test"], - inputSchema: { - type: "object", - properties: { - domain: { - type: "string", - description: "Domain to check", - }, - }, - required: ["domain"], - }, - exampleInput: { domain: "example.com" }, - exampleOutput: { - registrar: "IANA", - registration_age_days: 10950, - dns_records: true, - ssl_valid: true, - reputation_score: 85, - header_security_grade: "C", - }, - steps: [ - { - capabilitySlug: "whois-lookup", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "dns-lookup", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "ssl-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "domain-reputation", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { domain: "$input.domain" }, - }, - { - capabilitySlug: "header-security-check", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { url: "$input.domain" }, - }, - ], - }, - - // ── Cross-model demand solutions (Batch 2 expansion) ────────────────────── - - // ── Enhanced Due Diligence ── - { - slug: "enhanced-due-diligence", - name: "Enhanced Due Diligence", - marketingName: "Enhanced Due Diligence", - description: - "Comprehensive due diligence on a company: registry verification, beneficial ownership, sanctions screening, PEP screening, and adverse media analysis. Returns everything a compliance officer needs in one call.", - longDescription: - "Runs a full Enhanced Due Diligence (EDD) workflow: fetches official company registry data, looks up beneficial owners via OpenOwnership/Companies House, screens against international sanctions lists, checks for Politically Exposed Persons, and searches for adverse media coverage. All steps run in parallel where possible for fast results.", - agentDescription: - "full company due diligence, EDD check, enhanced due diligence company, KYC AML company check, compliance screening company, beneficial ownership sanctions PEP adverse media", - category: "compliance", - priceCents: 300, - componentSumCents: 150, - valueTier: "compliance", - maintenanceLevel: "low", - geography: "eu-global", - targetAudience: "Compliance teams, legal departments, investment firms, banks — anyone doing KYC/AML on companies", - transparencyTag: "mixed", - extendsWith: ["aml-risk-score", "insolvency-check", "domain-age-check"], - inputSchema: { - type: "object", - properties: { - company_name: { type: "string", description: "Company name to investigate" }, - country_code: { type: "string", description: "ISO 2-letter country code" }, - company_number: { type: "string", description: "Company registration number (optional)" }, - }, - required: ["company_name", "country_code"], - }, - exampleInput: { company_name: "Tesco PLC", country_code: "GB", company_number: "00445790" }, - exampleOutput: { - company: { name: "TESCO PLC", status: "active" }, - beneficial_owners: [{ name: "Example Owner", percentage: 15.2 }], - sanctions: { match: false }, - pep: { match: false }, - adverse_media: { risk_level: "low", findings: [] }, - }, - steps: [ - { - capabilitySlug: "uk-company-data", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { company_number: "$input.company_number" }, - }, - { - capabilitySlug: "beneficial-ownership-lookup", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { company_name: "$input.company_name", jurisdiction: "$input.country_code" }, - }, - { - capabilitySlug: "sanctions-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$input.company_name" }, - }, - { - capabilitySlug: "pep-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$input.company_name" }, - }, - { - capabilitySlug: "adverse-media-check", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$input.company_name" }, - }, - ], - }, - - // ── Customer Risk Screen ── - { - slug: "customer-risk-screen", - name: "Customer Risk Screen", - marketingName: "Customer Risk Screen", - description: - "AML screening for customer onboarding: sanctions check, PEP screening, and adverse media analysis in one call. The essential compliance screen for every new customer.", - longDescription: - "Runs the three core AML screening steps in parallel: sanctions list check (EU, US OFAC, UN, UK), Politically Exposed Persons screening, and adverse media search. Designed for high-volume customer onboarding — all steps run in parallel for sub-second results on cached data.", - agentDescription: - "AML screening customer, KYC check person, customer onboarding compliance, sanctions PEP adverse media screen, new customer risk check", - category: "compliance", - priceCents: 100, - componentSumCents: 45, - valueTier: "compliance", - maintenanceLevel: "low", - geography: "global", - targetAudience: "Any business onboarding customers — banks, fintechs, marketplaces, platforms with KYC requirements", - transparencyTag: "mixed", - extendsWith: ["aml-risk-score", "id-number-validate"], - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Person or company name to screen" }, - country_code: { type: "string", description: "Country code (optional)" }, - birth_date: { type: "string", description: "Date of birth for person screening (optional)" }, - }, - required: ["name"], - }, - exampleInput: { name: "Jane Doe", country_code: "GB" }, - exampleOutput: { - sanctions: { match: false }, - pep: { is_pep: false, total_matches: 0 }, - adverse_media: { risk_level: "none", total_findings: 0 }, - }, - steps: [ - { - capabilitySlug: "sanctions-check", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$input.name" }, - }, - { - capabilitySlug: "pep-check", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$input.name", country: "$input.country_code", birth_date: "$input.birth_date" }, - }, - { - capabilitySlug: "adverse-media-check", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$input.name" }, - }, - ], - }, - - // ── Contact Verification ── - { - slug: "contact-verify", - name: "Contact Verification", - marketingName: "Contact Verification", - description: - "Verify a contact's email, phone number, and address in parallel. Returns validation status for each channel. Essential for CRM hygiene and lead qualification.", - longDescription: - "Validates all three contact channels simultaneously: email (format + MX records + disposable detection), phone (format + type classification + carrier), and address (geocoding + component extraction). All steps run in parallel — results in under 2 seconds.", - agentDescription: - "verify contact details, validate email phone address, CRM data hygiene, lead qualification check, contact data verification", - category: "sales-outreach", - priceCents: 25, - componentSumCents: 8, - valueTier: "verification", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: "Sales teams, CRM administrators, marketing ops — anyone maintaining contact databases", - transparencyTag: "mixed", - extendsWith: ["phone-type-detect", "email-reputation-score", "address-geocode"], - inputSchema: { - type: "object", - properties: { - email: { type: "string", description: "Email address to verify" }, - phone_number: { type: "string", description: "Phone number to verify (optional)" }, - address: { type: "string", description: "Postal address to verify (optional)" }, - }, - required: ["email"], - }, - exampleInput: { email: "jane@acme.com", phone_number: "+447911123456", address: "10 Downing Street, London, UK" }, - exampleOutput: { - email: { valid: true, has_mx: true }, - phone: { valid: true, type: "mobile" }, - address: { valid: true, formatted: "10 Downing Street, London SW1A 2AA, UK" }, - }, - steps: [ - { - capabilitySlug: "email-validate", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { email: "$input.email" }, - }, - { - capabilitySlug: "phone-validate", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { phone_number: "$input.phone_number" }, - }, - { - capabilitySlug: "address-validate", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { address: "$input.address" }, - }, - ], - }, - - // ── Invoice Processing Pipeline ── - { - slug: "invoice-process", - name: "Invoice Processing Pipeline", - marketingName: "Invoice Processing Pipeline", - description: - "End-to-end invoice verification: extract structured data from the invoice, validate the vendor's VAT number, verify the payment IBAN, and convert currency if needed. Returns everything an AP automation agent needs.", - longDescription: - "Processes an invoice in four steps: (1) extracts structured data (vendor, amounts, line items, VAT, IBAN) using AI, (2) validates the VAT number against VIES, (3) validates the IBAN checksum and bank, (4) converts the total to a target currency. Steps 2-3 run in parallel after extraction.", - agentDescription: - "process invoice, extract invoice data, verify invoice VAT IBAN, AP automation, accounts payable processing, invoice validation pipeline", - category: "data-research", - priceCents: 50, - componentSumCents: 23, - valueTier: "verification", - maintenanceLevel: "low", - geography: "eu-global", - targetAudience: "Finance teams, AP automation, ERP integrations — anyone processing invoices", - transparencyTag: "mixed", - extendsWith: ["invoice-validate", "payment-reference-generate"], - inputSchema: { - type: "object", - properties: { - text: { type: "string", description: "Invoice text or OCR output" }, - target_currency: { type: "string", description: "Convert amounts to this currency (optional)" }, - }, - required: ["text"], - }, - exampleInput: { - text: "Invoice #2024-001\nFrom: Acme Ltd, VAT: GB123456789\nIBAN: GB82WEST12345698765432\nTotal: €1,250.00", - target_currency: "SEK", - }, - exampleOutput: { - extracted: { vendor: "Acme Ltd", vat_number: "GB123456789", total: 1250.0 }, - vat_valid: true, - iban_valid: true, - converted: { amount: 14375.0, currency: "SEK" }, - }, - steps: [ - { - capabilitySlug: "invoice-extract", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { text: "$input.text" }, - }, - { - capabilitySlug: "vat-validate", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { vat_number: "$steps[0].vat_number" }, - }, - { - capabilitySlug: "iban-validate", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { iban: "$steps[0].iban" }, - }, - { - capabilitySlug: "currency-convert", - stepOrder: 4, - canParallel: false, - parallelGroup: null, - inputMap: { from: "$steps[0].currency", to: "$input.target_currency", amount: "$steps[0].total_amount" }, - }, - ], - }, - - // ── Vendor Onboarding Check ── - { - slug: "vendor-onboard", - name: "Vendor Onboarding Check", - marketingName: "Vendor Onboarding Check", - description: - "Everything a procurement agent needs to onboard a new vendor: company verification, VAT validation, bank account verification, sanctions screening, and address validation. One call replaces a 45-minute manual check.", - longDescription: - "Runs five verification steps: (1) fetches company registry data, (2-5) validates VAT number, verifies IBAN, screens against sanctions lists, and validates the vendor address — all in parallel after the initial company lookup.", - agentDescription: - "vendor onboarding check, onboard new supplier, procurement compliance, verify vendor company VAT IBAN sanctions, supplier due diligence", - category: "compliance", - priceCents: 150, - componentSumCents: 104, - valueTier: "compliance", - maintenanceLevel: "low", - geography: "eu-global", - targetAudience: "Procurement teams, vendor management, supply chain compliance", - transparencyTag: "mixed", - extendsWith: ["beneficial-ownership-lookup", "insolvency-check", "domain-age-check"], - inputSchema: { - type: "object", - properties: { - company_name: { type: "string", description: "Vendor company name" }, - country_code: { type: "string", description: "Vendor country code" }, - vat_number: { type: "string", description: "Vendor VAT number (optional)" }, - iban: { type: "string", description: "Vendor bank IBAN (optional)" }, - address: { type: "string", description: "Vendor address (optional)" }, - }, - required: ["company_name", "country_code"], - }, - exampleInput: { company_name: "Volvo AB", country_code: "SE", vat_number: "SE556012573201", iban: "SE3550000000054910000003" }, - exampleOutput: { - company: { name: "Volvo AB", status: "active" }, - vat_valid: true, - iban_valid: true, - sanctions_match: false, - address_valid: true, - }, - steps: [ - { - capabilitySlug: "swedish-company-data", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { org_number: "$input.company_name" }, - }, - { - capabilitySlug: "vat-validate", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { vat_number: "$input.vat_number" }, - }, - { - capabilitySlug: "iban-validate", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { iban: "$input.iban" }, - }, - { - capabilitySlug: "sanctions-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$input.company_name" }, - }, - { - capabilitySlug: "address-validate", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { address: "$input.address" }, - }, - ], - }, - - // ── HR Candidate Screening ── - { - slug: "hr-candidate-screen", - name: "HR Candidate Screening", - marketingName: "HR Candidate Screening", - description: - "Screen a job candidate: parse their resume, validate their email and phone, and run sanctions and PEP checks. Returns structured candidate data with verification status.", - longDescription: - "Parses the resume to extract candidate name and details, then runs four verification steps in parallel: email validation, phone validation, sanctions screening, and PEP check. Designed for HR tech platforms and recruitment agencies in regulated industries.", - agentDescription: - "screen job candidate, HR background check, recruitment compliance, verify candidate email phone sanctions PEP, candidate screening pipeline", - category: "data-research", - priceCents: 80, - componentSumCents: 30, - valueTier: "verification", - maintenanceLevel: "low", - geography: "global", - targetAudience: "HR tech, recruitment platforms, staffing agencies, compliance-heavy industries", - transparencyTag: "mixed", - extendsWith: ["id-number-validate", "adverse-media-check"], - inputSchema: { - type: "object", - properties: { - resume_text: { type: "string", description: "Resume/CV text content" }, - email: { type: "string", description: "Candidate email (optional)" }, - phone_number: { type: "string", description: "Candidate phone (optional)" }, - }, - required: ["resume_text"], - }, - exampleInput: { - resume_text: "Jane Smith\nSenior Engineer at Acme Corp\njane@acme.com\n+44 7911 123456", - email: "jane@acme.com", - phone_number: "+447911123456", - }, - exampleOutput: { - candidate: { name: "Jane Smith", skills: ["engineering"] }, - email_valid: true, - phone_valid: true, - sanctions_match: false, - pep_match: false, - }, - steps: [ - { - capabilitySlug: "resume-parse", - stepOrder: 1, - canParallel: false, - parallelGroup: null, - inputMap: { text: "$input.resume_text" }, - }, - { - capabilitySlug: "email-validate", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { email: "$input.email" }, - }, - { - capabilitySlug: "phone-validate", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { phone_number: "$input.phone_number" }, - }, - { - capabilitySlug: "sanctions-check", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].name" }, - }, - { - capabilitySlug: "pep-check", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$steps[0].name" }, - }, - ], - }, - - // ── Crypto/Web3 solutions ────────────────────────────────────────────────── - - { - slug: "token-project-dd", - name: "Token Project Due Diligence", - marketingName: "Token Project Due Diligence", - description: - "Your agent is evaluating a token project or crypto startup. This solution checks the team's web presence, verifies the company entity, screens for sanctions, and assesses domain trust — the off-chain due diligence that on-chain analysis can't provide.", - longDescription: - "Detects the project's tech stack, validates SSL certificates, assesses domain reputation, looks up WHOIS records, and screens the team entity against global sanctions lists. Covers the off-chain risk factors that blockchain analytics miss: is the website real, is the domain trustworthy, is the team sanctioned?", - agentDescription: - "crypto due diligence, token project check, verify crypto startup, web3 team verification, off-chain risk check, token launch evaluation, crypto VC due diligence, defi project assessment", - category: "data-research", - priceCents: 200, - componentSumCents: 63, - valueTier: "data-lookup", - maintenanceLevel: "low", - geography: "global", - targetAudience: "Crypto VCs, token analysts, DeFi protocol evaluators, ai16z-style investment agents", - transparencyTag: "mixed", - extendsWith: ["landing-page-roast", "social-profile-check", "backlink-check"], - inputSchema: { - type: "object", - properties: { - project_url: { type: "string", format: "uri", description: "Token project website URL" }, - team_entity_name: { type: "string", description: "Legal entity or team name behind the project" }, - }, - required: ["project_url", "team_entity_name"], - }, - exampleInput: { project_url: "https://uniswap.org", team_entity_name: "Uniswap Labs" }, - exampleOutput: { - tech_stack: { frontend: "React", hosting: "Vercel" }, - ssl_valid: true, - domain_reputation: "clean", - domain_age_days: 1825, - is_sanctioned: false, - }, - steps: [ - { capabilitySlug: "tech-stack-detect", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { url: "$input.project_url" } }, - { capabilitySlug: "ssl-check", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.project_url" } }, - { capabilitySlug: "domain-reputation", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.project_url" } }, - { capabilitySlug: "whois-lookup", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { domain: "$input.project_url" } }, - { capabilitySlug: "sanctions-check", stepOrder: 5, canParallel: true, parallelGroup: 2, inputMap: { name: "$input.team_entity_name" } }, - ], - }, - - { - slug: "defi-risk-check", - name: "DeFi Protocol Risk Check", - marketingName: "DeFi Protocol Risk Check", - description: - "Before your agent deposits funds into a DeFi protocol, verify the team isn't sanctioned, the domain is legitimate, and the SSL certificate is valid. The off-chain risk layer for on-chain DeFi.", - longDescription: - "Validates the protocol's SSL certificate, assesses domain reputation, screens the protocol name against global sanctions lists, and looks up WHOIS records. Provides the off-chain trust signals that complement on-chain protocol analysis.", - agentDescription: - "defi risk check, protocol safety check, is this defi protocol safe, verify defi protocol, yield farming risk, defi due diligence, smart contract risk, protocol trust score", - category: "compliance-verification", - priceCents: 150, - componentSumCents: 43, - valueTier: "compliance", - maintenanceLevel: "low", - geography: "global", - targetAudience: "DeFi aggregators, yield farming agents, portfolio managers, risk teams", - transparencyTag: "mixed", - extendsWith: ["privacy-policy-analyze", "cookie-scan"], - inputSchema: { - type: "object", - properties: { - protocol_url: { type: "string", format: "uri", description: "DeFi protocol website URL" }, - protocol_name: { type: "string", description: "Protocol name (e.g. Aave, Compound)" }, - }, - required: ["protocol_url", "protocol_name"], - }, - exampleInput: { protocol_url: "https://aave.com", protocol_name: "Aave" }, - exampleOutput: { - ssl_valid: true, - domain_reputation: "clean", - is_sanctioned: false, - domain_registrar: "MarkMonitor", - }, - steps: [ - { capabilitySlug: "ssl-check", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.protocol_url" } }, - { capabilitySlug: "domain-reputation", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.protocol_url" } }, - { capabilitySlug: "sanctions-check", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { name: "$input.protocol_name" } }, - { capabilitySlug: "whois-lookup", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { domain: "$input.protocol_url" } }, - ], - }, - - { - slug: "crypto-counterparty-kyb", - name: "Crypto Counterparty KYB", - marketingName: "Crypto Counterparty KYB", - description: - "Know Your Business for crypto counterparties. Verify the legal entity exists, check sanctions lists, validate their web presence, and assess domain trust. The compliance layer agents need before transacting with a new counterparty.", - longDescription: - "Screens the counterparty against global sanctions lists (OFAC, EU, UN), assesses domain reputation, validates SSL certificates, looks up WHOIS records, and checks email deliverability for the entity's domain. Covers the off-chain compliance requirements for crypto-native businesses.", - agentDescription: - "crypto KYB, know your business crypto, verify crypto counterparty, OTC desk compliance, exchange counterparty check, institutional defi compliance, crypto entity verification", - category: "compliance-verification", - priceCents: 180, - componentSumCents: 53, - valueTier: "compliance", - maintenanceLevel: "low", - geography: "global", - targetAudience: "OTC desks, crypto exchanges, institutional DeFi, compliance teams", - transparencyTag: "mixed", - extendsWith: ["company-enrich", "pep-check", "adverse-media-check"], - inputSchema: { - type: "object", - properties: { - entity_name: { type: "string", description: "Legal entity or company name" }, - entity_website: { type: "string", format: "uri", description: "Entity's website URL" }, - }, - required: ["entity_name", "entity_website"], - }, - exampleInput: { entity_name: "Circle Internet Financial", entity_website: "https://circle.com" }, - exampleOutput: { - is_sanctioned: false, - domain_reputation: "clean", - ssl_valid: true, - email_deliverable: true, - domain_registrar: "MarkMonitor", - }, - steps: [ - { capabilitySlug: "sanctions-check", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { name: "$input.entity_name" } }, - { capabilitySlug: "domain-reputation", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.entity_website" } }, - { capabilitySlug: "ssl-check", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.entity_website" } }, - { capabilitySlug: "whois-lookup", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { domain: "$input.entity_website" } }, - { capabilitySlug: "email-deliverability-check", stepOrder: 5, canParallel: true, parallelGroup: 2, inputMap: { domain: "$input.entity_website" } }, - ], - }, - - // ── Web3 Solutions ──────────────────────────────────────────────────────── - - { - slug: "token-project-dd", - name: "Token Project Due Diligence", - marketingName: "Token Project Due Diligence", - description: - "Evaluates a token project's legitimacy before investment or integration. Checks domain infrastructure, security posture, sanctions status, and technology stack. The answer to \"is this token project real?\"", - category: "security-risk", - priceCents: 75, - componentSumCents: 52, - valueTier: "verification", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: - "Web3 fund compliance agents, DeFi risk bots, ElizaOS agents, Solana Agent Kit users, x402 clients evaluating counterparties", - transparencyTag: "mixed", - extendsWith: ["crypto-price", "us-company-data"], - inputSchema: { - type: "object", - properties: { - domain: { - type: "string", - description: "Token project website domain (e.g. uniswap.org)", - }, - entity_name: { - type: "string", - description: "Project or company name for sanctions screening", - }, - }, - required: ["domain", "entity_name"], - }, - exampleInput: { domain: "uniswap.org", entity_name: "Uniswap Labs" }, - exampleOutput: { - registrar: "Cloudflare Inc.", - ssl_valid: true, - days_until_expiry: 312, - reputation_score: 91, - header_security_grade: "B", - is_sanctioned: false, - tech_stack: { frontend: "React", hosting: "Cloudflare" }, - }, - steps: [ - { capabilitySlug: "whois-lookup", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "ssl-check", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "dns-lookup", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "domain-reputation", stepOrder: 4, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "header-security-check", stepOrder: 5, canParallel: true, parallelGroup: 1, inputMap: { url: "$input.domain" } }, - { capabilitySlug: "sanctions-check", stepOrder: 6, canParallel: true, parallelGroup: 1, inputMap: { name: "$input.entity_name" } }, - { capabilitySlug: "tech-stack-detect", stepOrder: 7, canParallel: true, parallelGroup: 1, inputMap: { url: "$input.domain" } }, - ], - }, - { - slug: "defi-protocol-risk", - name: "DeFi Protocol Risk Check", - marketingName: "DeFi Protocol Risk Check", - description: - "Evaluates a DeFi protocol's infrastructure security before interaction. Checks SSL certificate health, HTTP security headers, DNS configuration, domain reputation, and technology surface area. Answers \"is this protocol's infrastructure secure?\"", - category: "security-risk", - priceCents: 50, - componentSumCents: 32, - valueTier: "data-lookup", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: - "DeFi risk agents, yield farming bots, portfolio management agents, x402 clients, Solana Agent Kit users", - transparencyTag: "mixed", - extendsWith: ["crypto-price", "whois-lookup", "sanctions-check"], - inputSchema: { - type: "object", - properties: { - domain: { - type: "string", - description: "DeFi protocol domain (e.g. app.aave.com)", - }, - }, - required: ["domain"], - }, - exampleInput: { domain: "app.aave.com" }, - exampleOutput: { - ssl_valid: true, - days_until_expiry: 245, - header_security_grade: "A", - reputation_score: 94, - dns_records: true, - tech_stack: { frontend: "React", hosting: "Cloudflare" }, - }, - steps: [ - { capabilitySlug: "ssl-check", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "header-security-check", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { url: "$input.domain" } }, - { capabilitySlug: "dns-lookup", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "domain-reputation", stepOrder: 4, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "tech-stack-detect", stepOrder: 5, canParallel: true, parallelGroup: 1, inputMap: { url: "$input.domain" } }, - ], - }, - { - slug: "web3-counterparty-kyb", - name: "Web3 Counterparty KYB", - marketingName: "Web3 Counterparty KYB", - description: - "Counterparty due diligence for Web3 companies, DAOs, and token projects. Sanctions screening on the entity plus full domain infrastructure audit. Answers \"should we transact with this counterparty?\"", - category: "security-risk", - priceCents: 70, - componentSumCents: 49, - valueTier: "verification", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: - "Web3 OTC desks, DAO treasury agents, DeFi lending protocol risk engines, x402 payment agents, ElizaOS agents", - transparencyTag: "mixed", - extendsWith: ["crypto-price", "us-company-data", "tech-stack-detect"], - inputSchema: { - type: "object", - properties: { - entity_name: { - type: "string", - description: "Company, DAO, or project name for sanctions screening", - }, - domain: { - type: "string", - description: "Counterparty website domain", - }, - }, - required: ["entity_name", "domain"], - }, - exampleInput: { entity_name: "Circle Internet Financial", domain: "circle.com" }, - exampleOutput: { - is_sanctioned: false, - confidence: "high", - registrar: "MarkMonitor Inc.", - ssl_valid: true, - reputation_score: 96, - header_security_grade: "A", - dns_records: true, - }, - steps: [ - { capabilitySlug: "sanctions-check", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { name: "$input.entity_name" } }, - { capabilitySlug: "whois-lookup", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "ssl-check", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "domain-reputation", stepOrder: 4, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - { capabilitySlug: "header-security-check", stepOrder: 5, canParallel: true, parallelGroup: 1, inputMap: { url: "$input.domain" } }, - { capabilitySlug: "dns-lookup", stepOrder: 6, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.domain" } }, - ], - }, - - // ═══════════════════════════════════════════════════════════════════════════ - // Web3 Agent Solutions — enriched multi-source workflows - // ═══════════════════════════════════════════════════════════════════════════ - - { - slug: "web3-counterparty-dd", - name: "Web3 Counterparty Due Diligence", - marketingName: "Web3 Counterparty Due Diligence", - description: "Full compliance check on a wallet address before transacting. Combines on-chain risk scoring with off-chain entity compliance — wallet risk, wallet age, ENS identity resolution, sanctions screening, PEP check, and adverse media scan.", - agentDescription: "web3 counterparty due diligence, wallet kyc, wallet compliance check, defi counterparty risk, on-chain aml, wallet sanctions check, crypto counterparty screening", - category: "web3", - priceCents: 15, - componentSumCents: 12, - valueTier: "compliance", - maintenanceLevel: "low", - geography: "global", - targetAudience: "DeFi lending agents, OTC desks, DAO treasury managers, compliance-aware trading bots", - transparencyTag: "mixed", - extendsWith: ["beneficial-ownership-lookup", "company-enrich"], - inputSchema: { - type: "object", - properties: { - wallet_address: { type: "string", description: "Wallet address to investigate (0x...)" }, - entity_name: { type: "string", description: "Entity or person name for sanctions/PEP check (if known)" }, - chain_id: { type: "string", description: "Chain ID, default 1 (Ethereum)" }, - }, - required: ["wallet_address"], - }, - exampleInput: { wallet_address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", entity_name: "Vitalik Buterin", chain_id: "1" }, - steps: [ - { capabilitySlug: "wallet-risk-score", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "wallet-age-check", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "ens-reverse-lookup", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.wallet_address" } }, - { capabilitySlug: "sanctions-check", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { name: "$input.entity_name" } }, - { capabilitySlug: "pep-check", stepOrder: 5, canParallel: true, parallelGroup: 2, inputMap: { name: "$input.entity_name" } }, - { capabilitySlug: "adverse-media-check", stepOrder: 6, canParallel: true, parallelGroup: 2, inputMap: { name: "$input.entity_name" } }, - ], - }, - { - slug: "web3-token-safety", - name: "Web3 Token Safety Check", - marketingName: "Web3 Token Safety Check", - description: "Before buying a token, verify the contract isn't a scam. Checks for honeypot behavior, hidden ownership, mint functions, and verifies contract source is public. Also checks the deployer wallet for fraud history and the project's domain reputation.", - agentDescription: "token safety check, is this token a scam, honeypot check, rug pull detection, token contract audit, defi token risk, erc20 safety", - category: "web3", - priceCents: 8, - componentSumCents: 8, - valueTier: "verification", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: "DeFi trading agents, portfolio managers, token listing screeners, investor research bots", - transparencyTag: null, - extendsWith: ["whois-lookup", "ssl-check", "tech-stack-detect"], - inputSchema: { - type: "object", - properties: { - contract_address: { type: "string", description: "Token contract address (0x...)" }, - project_domain: { type: "string", description: "Project website domain for reputation check" }, - chain_id: { type: "string", description: "Chain ID, default 1" }, - }, - required: ["contract_address"], - }, - exampleInput: { contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", project_domain: "circle.com", chain_id: "1" }, - steps: [ - { capabilitySlug: "token-security-check", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { contract_address: "$input.contract_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "contract-verify-check", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { contract_address: "$input.contract_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "wallet-risk-score", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.contract_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "domain-reputation", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { domain: "$input.project_domain" } }, - ], - }, - { - slug: "web3-pre-tx-gate", - name: "Web3 Pre-Transaction Risk Gate", - marketingName: "Web3 Pre-Transaction Risk Gate", - description: "Should your agent execute this transaction? Single-call go/no-go middleware for DeFi agents. Checks wallet risk, token safety, approval security, current gas costs, and sanctions status. Returns a risk verdict.", - agentDescription: "pre-transaction check, should i execute this trade, defi safety gate, transaction risk check, agent middleware, trading bot safety, smart contract interaction check", - category: "web3", - priceCents: 15, - componentSumCents: 10, - valueTier: "compliance", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: "Autonomous DeFi agents, trading bots with safety rails, agent frameworks needing pre-flight checks", - transparencyTag: null, - extendsWith: ["phishing-site-check"], - inputSchema: { - type: "object", - properties: { - wallet_address: { type: "string", description: "Counterparty or target wallet address" }, - contract_address: { type: "string", description: "Token contract if relevant" }, - entity_name: { type: "string", description: "Entity name for sanctions check" }, - chain_id: { type: "string", description: "Chain ID, default 1" }, - }, - required: ["wallet_address"], - }, - exampleInput: { wallet_address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", entity_name: "Circle", chain_id: "1" }, - steps: [ - { capabilitySlug: "wallet-risk-score", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "token-security-check", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { contract_address: "$input.contract_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "approval-security-check", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "gas-price-check", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { chain_id: "$input.chain_id" } }, - { capabilitySlug: "sanctions-check", stepOrder: 5, canParallel: true, parallelGroup: 2, inputMap: { name: "$input.entity_name" } }, - ], - }, - { - slug: "web3-dapp-trust", - name: "Web3 dApp Trust Scan", - marketingName: "Web3 dApp Trust Scan", - description: "Before connecting your wallet to a dApp, verify it's not a phishing clone. Combines GoPlus phishing detection with deep domain intelligence — reputation scoring, SSL verification, WHOIS data, and technology stack analysis.", - agentDescription: "dapp trust check, is this dapp safe, phishing detection, wallet connect safety, dapp security scan, web3 phishing check, fake dapp detection", - category: "web3", - priceCents: 8, - componentSumCents: 8, - valueTier: "verification", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: "Wallet security agents, browser extension bots, dApp aggregator screeners", - transparencyTag: null, - extendsWith: ["header-security-check", "dns-lookup"], - inputSchema: { - type: "object", - properties: { - url: { type: "string", description: "dApp URL to check" }, - }, - required: ["url"], - }, - exampleInput: { url: "https://app.uniswap.org" }, - steps: [ - { capabilitySlug: "phishing-site-check", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { url: "$input.url" } }, - { capabilitySlug: "domain-reputation", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.url" } }, - { capabilitySlug: "ssl-check", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { domain: "$input.url" } }, - { capabilitySlug: "whois-lookup", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { domain: "$input.url" } }, - { capabilitySlug: "tech-stack-detect", stepOrder: 5, canParallel: true, parallelGroup: 2, inputMap: { url: "$input.url" } }, - ], - }, - { - slug: "web3-protocol-health", - name: "Web3 Protocol Health Report", - marketingName: "Web3 Protocol Health Report", - description: "Full health snapshot of any DeFi protocol. TVL, fee revenue, stablecoin exposure, plus infrastructure trust assessment. Answers: is this protocol real AND healthy?", - agentDescription: "protocol health check, defi protocol analysis, tvl check, protocol revenue, is this protocol healthy, defi due diligence, yield farming research", - category: "web3", - priceCents: 8, - componentSumCents: 8, - valueTier: "data-lookup", - maintenanceLevel: "low", - geography: "global", - targetAudience: "DeFi yield agents, protocol comparison tools, treasury allocation bots, risk dashboards", - transparencyTag: null, - extendsWith: ["landing-page-roast", "social-profile-check"], - inputSchema: { - type: "object", - properties: { - protocol: { type: "string", description: "Protocol name or DeFi Llama slug (e.g., 'aave-v3', 'uniswap')" }, - protocol_domain: { type: "string", description: "Protocol website domain for infrastructure check" }, - }, - required: ["protocol"], - }, - exampleInput: { protocol: "aave-v3", protocol_domain: "aave.com" }, - steps: [ - { capabilitySlug: "protocol-tvl-lookup", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { protocol: "$input.protocol" } }, - { capabilitySlug: "protocol-fees-lookup", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { protocol: "$input.protocol" } }, - { capabilitySlug: "stablecoin-flow-check", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { chain: "Ethereum" } }, - { capabilitySlug: "domain-reputation", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { domain: "$input.protocol_domain" } }, - { capabilitySlug: "ssl-check", stepOrder: 5, canParallel: true, parallelGroup: 2, inputMap: { domain: "$input.protocol_domain" } }, - ], - }, - { - slug: "web3-pre-trade", - name: "Web3 Pre-Trade Intelligence", - marketingName: "Web3 Pre-Trade Intelligence", - description: "Everything a trading agent needs before placing a trade. Market price, token contract safety, protocol health, deployer wallet risk, market sentiment, and gas costs — all in one call.", - agentDescription: "pre-trade intelligence, should i buy this token, trading bot research, defi trade analysis, token price and safety, market sentiment check, gas cost check", - category: "web3", - priceCents: 12, - componentSumCents: 12, - valueTier: "data-lookup", - maintenanceLevel: "low", - geography: "global", - targetAudience: "Autonomous trading bots, portfolio rebalancing agents, DeFi yield optimizers, quantitative trading systems", - transparencyTag: null, - extendsWith: ["stablecoin-flow-check", "wallet-age-check"], - inputSchema: { - type: "object", - properties: { - token_id: { type: "string", description: "CoinGecko token ID or symbol (e.g., 'ethereum', 'bitcoin')" }, - contract_address: { type: "string", description: "Token contract address for security check" }, - protocol: { type: "string", description: "DeFi Llama protocol slug for TVL check" }, - chain_id: { type: "string", description: "Chain ID, default 1" }, - }, - required: ["token_id"], - }, - exampleInput: { token_id: "ethereum", contract_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", protocol: "aave-v3", chain_id: "1" }, - steps: [ - { capabilitySlug: "crypto-price", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { coin: "$input.token_id" } }, - { capabilitySlug: "token-security-check", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { contract_address: "$input.contract_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "protocol-tvl-lookup", stepOrder: 3, canParallel: true, parallelGroup: 1, inputMap: { protocol: "$input.protocol" } }, - { capabilitySlug: "wallet-risk-score", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { address: "$input.contract_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "fear-greed-index", stepOrder: 5, canParallel: true, parallelGroup: 2, inputMap: {} }, - { capabilitySlug: "gas-price-check", stepOrder: 6, canParallel: true, parallelGroup: 2, inputMap: { chain_id: "$input.chain_id" } }, - ], - }, - { - slug: "web3-wallet-snapshot", - name: "Web3 Wallet Portfolio Snapshot", - marketingName: "Web3 Wallet Portfolio Snapshot", - description: "Understand any wallet before acting. Balance, recent transactions, age, ENS identity, and current market prices — the quickest way to size up a wallet.", - agentDescription: "wallet snapshot, wallet portfolio, wallet analysis, wallet balance check, wallet history, wallet identity, wallet age check", - category: "web3", - priceCents: 5, - componentSumCents: 5, - valueTier: "data-lookup", - maintenanceLevel: "low", - geography: "global", - targetAudience: "Portfolio tracking agents, wallet analytics tools, counterparty screening, airdrop eligibility checkers", - transparencyTag: null, - extendsWith: ["wallet-risk-score", "approval-security-check"], - inputSchema: { - type: "object", - properties: { - wallet_address: { type: "string", description: "Wallet address (0x...)" }, - chain_id: { type: "string", description: "Chain ID, default 1" }, - }, - required: ["wallet_address"], - }, - exampleInput: { wallet_address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", chain_id: "1" }, - steps: [ - { capabilitySlug: "wallet-balance-lookup", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "wallet-transactions-lookup", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id", limit: "10" } }, - { capabilitySlug: "wallet-age-check", stepOrder: 3, canParallel: true, parallelGroup: 2, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "ens-reverse-lookup", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { address: "$input.wallet_address" } }, - { capabilitySlug: "crypto-price", stepOrder: 5, canParallel: true, parallelGroup: 2, inputMap: { coin: "ethereum" } }, - ], - }, - { - slug: "web3-vasp-check", - name: "Web3 VASP Compliance Check", - marketingName: "Web3 VASP Compliance Check", - description: "Before interacting with a crypto service provider, verify they're licensed under EU MiCA regulation. Checks the ESMA register of authorized CASPs, the non-compliant entity list, sanctions status, and domain reputation.", - agentDescription: "vasp compliance, mica check, casp verification, crypto exchange license check, eu crypto regulation, vasp authorization, exchange compliance", - category: "web3", - priceCents: 10, - componentSumCents: 8, - valueTier: "compliance", - maintenanceLevel: "low", - geography: "eu", - targetAudience: "Compliance agents, exchange screening bots, crypto fund due diligence, VASP onboarding automation", - transparencyTag: null, - extendsWith: ["pep-check", "adverse-media-check", "whois-lookup"], - inputSchema: { - type: "object", - properties: { - entity_name: { type: "string", description: "CASP/VASP name or company name" }, - website: { type: "string", description: "Entity website domain" }, - }, - required: ["entity_name"], - }, - exampleInput: { entity_name: "Coinbase", website: "coinbase.com" }, - steps: [ - { capabilitySlug: "vasp-verify", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { entity_name: "$input.entity_name", website: "$input.website" } }, - { capabilitySlug: "vasp-non-compliant-check", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { entity_name: "$input.entity_name", website: "$input.website" } }, - { capabilitySlug: "sanctions-check", stepOrder: 3, canParallel: true, parallelGroup: 2, inputMap: { name: "$input.entity_name" } }, - { capabilitySlug: "domain-reputation", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { domain: "$input.website" } }, - ], - }, - { - slug: "web3-wallet-identity", - name: "Web3 Wallet Identity Resolution", - marketingName: "Web3 Wallet Identity Resolution", - description: "Given a wallet address, discover everything knowable about the entity behind it. Starts with ENS reverse lookup to find a human-readable name, then assesses risk, age, and holdings. The entry point to the full compliance pipeline.", - agentDescription: "wallet identity, who owns this wallet, wallet deanonymization, wallet reputation, wallet kyc, ens lookup, wallet profiling", - category: "web3", - priceCents: 10, - componentSumCents: 8, - valueTier: "data-lookup", - maintenanceLevel: "low", - geography: "global", - targetAudience: "KYC/AML agents, fraud investigation bots, counterparty research tools, compliance automation", - transparencyTag: null, - extendsWith: ["sanctions-check", "pep-check", "company-enrich"], - inputSchema: { - type: "object", - properties: { - wallet_address: { type: "string", description: "Wallet address to investigate (0x...)" }, - chain_id: { type: "string", description: "Chain ID, default 1" }, - }, - required: ["wallet_address"], - }, - exampleInput: { wallet_address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", chain_id: "1" }, - steps: [ - { capabilitySlug: "ens-reverse-lookup", stepOrder: 1, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.wallet_address" } }, - { capabilitySlug: "wallet-risk-score", stepOrder: 2, canParallel: true, parallelGroup: 1, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "wallet-age-check", stepOrder: 3, canParallel: true, parallelGroup: 2, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id" } }, - { capabilitySlug: "wallet-balance-lookup", stepOrder: 4, canParallel: true, parallelGroup: 2, inputMap: { address: "$input.wallet_address", chain_id: "$input.chain_id" } }, - ], - }, - - // ── Dependency Risk Check ── - { - slug: "nl-property-check", - name: "Netherlands Property Check", - marketingName: "Netherlands Property Intelligence", - description: - "Comprehensive Dutch property market intelligence. Combines BAG address data, WOZ tax assessments, energy labels, housing prices, and regional statistics into one call.", - longDescription: - "Full Netherlands property analysis: official BAG address/building data from Kadaster, municipality-level WOZ tax values and trends from CBS, energy performance labels from EP-Online, national housing price index, and detailed housing statistics. Covers construction year, floor area, ownership patterns, and market trends. Three CBS data sources require no API key; BAG and EP-Online use free registered keys.", - agentDescription: - "netherlands property check, dutch real estate data, BAG address lookup, WOZ waarde, energielabel, housing price index, CBS housing statistics, kadaster, nl property", - category: "data-lookup", - priceCents: 19, - componentSumCents: 19, - valueTier: "data-lookup", - maintenanceLevel: "near-zero", - geography: "eu", - targetAudience: - "Real estate agents, property investors, mortgage brokers, proptech companies operating in the Netherlands", - transparencyTag: null, - extendsWith: ["dutch-company-data"], - inputSchema: { - type: "object", - properties: { - postcode: { type: "string", description: "Dutch postcode (format 1234AB)" }, - huisnummer: { type: "string", description: "House number" }, - city: { type: "string", description: "Municipality name (e.g. 'Amsterdam') — used for CBS stats" }, - }, - required: ["city"], - }, - exampleInput: { postcode: "1012JS", huisnummer: "1", city: "Amsterdam" }, - exampleOutput: { - bag_address: { street: "Dam", city: "Amsterdam", construction_year: 1900 }, - woz_value: { average_woz_value_eur: 516000, latest_year: 2023 }, - energy_label: { energy_label: "C" }, - housing_prices: { price_index: 138.2, average_sale_price_eur: 438000 }, - housing_stats: { sale_prices: { average_sale_price_eur: 520000 } }, - }, - steps: [ - { - capabilitySlug: "nl-bag-address", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { postcode: "$input.postcode", huisnummer: "$input.huisnummer" }, - }, - { - capabilitySlug: "nl-woz-value", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { city: "$input.city" }, - }, - { - capabilitySlug: "nl-energy-label", - stepOrder: 3, - canParallel: true, - parallelGroup: 1, - inputMap: { postcode: "$input.postcode", huisnummer: "$input.huisnummer" }, - }, - { - capabilitySlug: "nl-housing-price-index", - stepOrder: 4, - canParallel: true, - parallelGroup: 1, - inputMap: { months: "12" }, - }, - { - capabilitySlug: "nl-housing-stats", - stepOrder: 5, - canParallel: true, - parallelGroup: 1, - inputMap: { city: "$input.city" }, - }, - ], - }, - { - slug: "dependency-risk-check", - name: "Dependency Risk Check", - marketingName: "Dependency Risk Assessment", - description: - "Complete security and license risk assessment for any npm or PyPI package. Combines CVE scanning (OSV.dev), OpenSSF Scorecard analysis, freshness check, and license compatibility verification into one call.", - longDescription: - "Answers: is this dependency safe to use in my project? Runs a composite CVE check via OSV.dev, fetches the OpenSSF Scorecard, checks package freshness and deprecation status, and verifies license compatibility with your use case. All data sources are free and require no API keys.", - agentDescription: - "dependency risk check, package security audit, npm pypi vulnerability scan, license compatibility, OpenSSF scorecard, CVE check, dependency audit", - category: "security", - priceCents: 25, - componentSumCents: 20, - valueTier: "verification", - maintenanceLevel: "near-zero", - geography: "global", - targetAudience: - "Developers using coding agents (Claude Code, Cursor, Windsurf), CI/CD pipelines, security teams evaluating dependencies", - transparencyTag: null, - extendsWith: ["npm-package-info", "pypi-package-info", "cve-lookup"], - inputSchema: { - type: "object", - properties: { - name: { type: "string", description: "Package name (e.g. 'express', 'requests')" }, - version: { type: "string", description: "Version to audit (optional — defaults to latest)" }, - ecosystem: { type: "string", enum: ["npm", "pypi"], description: "Package ecosystem (optional — auto-detected)" }, - use_case: { type: "string", enum: ["commercial", "open-source", "saas", "internal"], description: "How the software will be used (for license check). Default: commercial" }, - }, - required: ["name"], - }, - exampleInput: { name: "express", version: "4.18.2", use_case: "commercial" }, - exampleOutput: { - security_audit: { risk_score: 82, risk_level: "low", vulnerabilities: { total: 1 } }, - license_check: { compatible: true, conflicts: [] }, - }, - steps: [ - { - capabilitySlug: "package-security-audit", - stepOrder: 1, - canParallel: true, - parallelGroup: 1, - inputMap: { name: "$input.name", version: "$input.version", ecosystem: "$input.ecosystem" }, - }, - { - capabilitySlug: "license-compatibility-check", - stepOrder: 2, - canParallel: true, - parallelGroup: 1, - inputMap: { licenses: "$steps[0].license.spdx", use_case: "$input.use_case" }, - }, - ], - }, -]; - -// ─── Seed logic ───────────────────────────────────────────────────────────── - -async function seed() { - const db = getDb(); - - // Collect all capability slugs referenced by solutions (steps + extendsWith) - const allSlugs = [ - ...new Set([ - ...SOLUTIONS.flatMap((s) => s.steps.map((st) => st.capabilitySlug)), - ...SOLUTIONS.flatMap((s) => s.extendsWith), - ]), - ]; - - // Verify they exist in the database - const capRows = await db - .select({ slug: capabilities.slug }) - .from(capabilities) - .where(inArray(capabilities.slug, allSlugs)); - const existingSlugs = new Set(capRows.map((r) => r.slug)); - const missing = allSlugs.filter((s) => !existingSlugs.has(s)); - if (missing.length) { - console.warn(`WARNING: Missing capabilities: ${missing.join(", ")}`); - } - - let seeded = 0; - let skipped = 0; - - for (const sol of SOLUTIONS) { - // Check if any step references a missing capability - const missingSlugs = sol.steps.filter( - (st) => !existingSlugs.has(st.capabilitySlug), - ); - if (missingSlugs.length) { - console.warn( - ` SKIP ${sol.slug} — missing: ${missingSlugs.map((s) => s.capabilitySlug).join(", ")}`, - ); - skipped++; - continue; - } - - const complianceCoverage = buildComplianceCoverage(sol); - - // Gate checks: validate solution before writing - const gateViolations = await validateSolution( - sol.slug, - sol.inputSchema, - sol.steps.map((s) => ({ capabilitySlug: s.capabilitySlug, stepOrder: s.stepOrder, inputMap: s.inputMap })), - ); - enforceGates(gateViolations); - - await db.transaction(async (tx) => { - // Upsert solution - const [existing] = await tx - .select({ id: solutions.id }) - .from(solutions) - .where(eq(solutions.slug, sol.slug)) - .limit(1); - - let solutionId: string; - - if (existing) { - // Update existing - await tx - .update(solutions) - .set({ - name: sol.name, - description: sol.description, - longDescription: sol.longDescription ?? null, - agentDescription: sol.agentDescription ?? null, - category: sol.category, - priceCents: sol.priceCents, - componentSumCents: sol.componentSumCents, - valueTier: sol.valueTier, - maintenanceLevel: sol.maintenanceLevel, - geography: sol.geography, - inputSchema: sol.inputSchema, - exampleInput: sol.exampleInput ?? null, - exampleOutput: sol.exampleOutput ?? null, - targetAudience: sol.targetAudience, - marketingName: sol.marketingName, - transparencyTag: sol.transparencyTag, - extendsWith: sol.extendsWith, - complianceCoverage, - displayOrder: seeded, - updatedAt: new Date(), - }) - .where(eq(solutions.id, existing.id)); - solutionId = existing.id; - - // Delete old steps - await tx - .delete(solutionSteps) - .where(eq(solutionSteps.solutionId, solutionId)); - } else { - // Insert new - const [inserted] = await tx - .insert(solutions) - .values({ - slug: sol.slug, - name: sol.name, - description: sol.description, - longDescription: sol.longDescription ?? null, - agentDescription: sol.agentDescription ?? null, - category: sol.category, - priceCents: sol.priceCents, - componentSumCents: sol.componentSumCents, - valueTier: sol.valueTier, - maintenanceLevel: sol.maintenanceLevel, - geography: sol.geography, - inputSchema: sol.inputSchema, - exampleInput: sol.exampleInput ?? null, - exampleOutput: sol.exampleOutput ?? null, - targetAudience: sol.targetAudience, - marketingName: sol.marketingName, - transparencyTag: sol.transparencyTag, - extendsWith: sol.extendsWith, - complianceCoverage, - displayOrder: seeded, - }) - .returning({ id: solutions.id }); - solutionId = inserted.id; - } - - // Insert steps - await tx.insert(solutionSteps).values( - sol.steps.map((step) => ({ - solutionId, - capabilitySlug: step.capabilitySlug, - stepOrder: step.stepOrder, - canParallel: step.canParallel, - parallelGroup: step.parallelGroup, - inputMap: step.inputMap, - })), - ); - - console.log( - ` ${existing ? "UPDATED" : "INSERTED"} ${sol.slug} (${sol.steps.length} steps)`, - ); - }); - - seeded++; - } - - console.log( - `\nDone: ${seeded} solutions seeded, ${skipped} skipped.`, - ); - - // ── Solution quality gate ────────────────────────────────────────────────── - // Deactivate solutions where any step has SQS 0 (no test data). - // Auto-reactivate when all steps become qualified. - console.log("\n--- Solution quality gate ---"); - let gated = 0; - let activated = 0; - - const allSols = await db.select({ - id: solutions.id, - slug: solutions.slug, - isActive: solutions.isActive, - }).from(solutions); - - for (const sol of allSols) { - const steps = await db.select({ - capabilitySlug: solutionSteps.capabilitySlug, - }).from(solutionSteps).where(eq(solutionSteps.solutionId, sol.id)); - - if (steps.length === 0) continue; - - // Check each step's capability for SQS > 0 - const stepSlugs = steps.map((s) => s.capabilitySlug); - const capRows = await db.select({ - slug: capabilities.slug, - matrixSqs: capabilities.matrixSqs, - }).from(capabilities).where(inArray(capabilities.slug, stepSlugs)); - - const capMap = new Map(capRows.map((c) => [c.slug, c.matrixSqs])); - const unqualified = stepSlugs.filter((slug) => { - const sqs = capMap.get(slug); - return !sqs || parseFloat(String(sqs)) === 0; - }); - - if (unqualified.length > 0 && sol.isActive) { - await db.update(solutions) - .set({ isActive: false, updatedAt: new Date() }) - .where(eq(solutions.id, sol.id)); - console.log(` GATED: ${sol.slug} — unqualified: ${unqualified.join(', ')}`); - gated++; - } else if (unqualified.length === 0 && !sol.isActive) { - await db.update(solutions) - .set({ isActive: true, updatedAt: new Date() }) - .where(eq(solutions.id, sol.id)); - console.log(` ACTIVATED: ${sol.slug} — all steps qualified`); - activated++; - } - } - - console.log(`Quality gate: ${gated} gated, ${activated} activated`); - process.exit(0); -} - -seed().catch((err) => { - console.error("Seed failed:", err); - process.exit(1); -}); diff --git a/apps/api/src/lib/suggest.ts b/apps/api/src/lib/suggest.ts index 968a96ce..b570fa90 100644 --- a/apps/api/src/lib/suggest.ts +++ b/apps/api/src/lib/suggest.ts @@ -1,15 +1,11 @@ -import { eq, and, asc, inArray, sql } from "drizzle-orm"; +import { eq, and, inArray, sql } from "drizzle-orm"; import Anthropic from "@anthropic-ai/sdk"; import { getDb } from "../db/index.js"; -import { - solutions, - solutionSteps, - capabilities, -} from "../db/schema.js"; +import { capabilities } from "../db/schema.js"; import { embedQuery, embedDocuments, cosineSimilarity } from "./embeddings.js"; import { tokenize } from "./tokenize.js"; import { determineBadge } from "./trust-helpers.js"; -import { sqsLabel as sharedSqsLabel, computeSolutionScore } from "./trust-labels.js"; +import { sqsLabel as sharedSqsLabel } from "./trust-labels.js"; import { log, logError, logWarn } from "./log.js"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -172,74 +168,6 @@ async function loadCatalog(): Promise { catalogLoading = (async () => { try { const db = getDb(); - // Load solutions with steps - const solRows = await db - .select({ - id: solutions.id, - slug: solutions.slug, - name: solutions.name, - description: solutions.description, - agentDescription: solutions.agentDescription, - category: solutions.category, - priceCents: solutions.priceCents, - geography: solutions.geography, - }) - .from(solutions) - .where(eq(solutions.isActive, true)) - .orderBy(asc(solutions.displayOrder)); - // Batch-fetch ALL steps for ALL solutions in one query (eliminates N+1) - const solIds = solRows.map((s) => s.id); - const allSolSteps = solIds.length > 0 - ? await db - .select({ - solutionId: solutionSteps.solutionId, - capabilitySlug: solutionSteps.capabilitySlug, - parallelGroup: solutionSteps.parallelGroup, - capabilityName: capabilities.name, - }) - .from(solutionSteps) - .leftJoin(capabilities, eq(solutionSteps.capabilitySlug, capabilities.slug)) - .where(inArray(solutionSteps.solutionId, solIds)) - .orderBy(solutionSteps.solutionId, asc(solutionSteps.stepOrder)) - : []; - const stepsBySolId = new Map(); - for (const step of allSolSteps) { - const list = stepsBySolId.get(step.solutionId) ?? []; - list.push(step); - stepsBySolId.set(step.solutionId, list); - } - - const solItems: CatalogItem[] = solRows.map((sol) => { - const steps = stepsBySolId.get(sol.id) ?? []; - const stepNames = steps - .map((s) => s.capabilityName ?? s.capabilitySlug) - .join(", "); - const embeddingText = sol.agentDescription - ? `${sol.name}. ${sol.agentDescription}. ${sol.description}. Category: ${sol.category}. Geography: ${sol.geography}. Includes: ${stepNames}.` - : `${sol.name}. ${sol.description}. Category: ${sol.category}. Geography: ${sol.geography}. Includes: ${stepNames}.`; - const tokenText = `${sol.name} ${sol.description} ${sol.agentDescription ?? ""} ${sol.category} ${sol.geography} ${sol.slug} ${steps.map((s) => s.capabilitySlug).join(" ")}`; - - return { - type: "solution" as const, - slug: sol.slug, - name: sol.name, - description: sol.description, - category: sol.category, - priceCents: sol.priceCents, - isFreeTier: false, - geography: sol.geography, - steps: steps.map((s) => ({ - name: s.capabilityName ?? s.capabilitySlug, - capabilitySlug: s.capabilitySlug, - parallelGroup: s.parallelGroup, - })), - stepCount: steps.length, - embedding: [], - embeddingText, - tokens: tokenize(tokenText), - }; - }); - // Load capabilities const capRows = await db .select({ @@ -280,29 +208,10 @@ async function loadCatalog(): Promise { }; }); - const allItems = [...solItems, ...capItems]; - - // Build reverse index: capability → solutions containing it - for (const cap of capItems) { - const parents = solItems.filter((sol) => - sol.steps?.some((s) => s.capabilitySlug === cap.slug), - ); - if (parents.length > 0) { - cap.partOfSolutions = parents.map((sol) => ({ - slug: sol.slug, - name: sol.name, - priceCents: sol.priceCents, - otherCapabilityNames: (sol.steps ?? []) - .filter((s) => s.capabilitySlug !== cap.slug) - .map((s) => s.name), - })); - } - } + const allItems = capItems; - // Batch-fetch persisted SQS scores and test counts (2-3 queries instead of ~5,355) - const allCapSlugs = capItems.map((c) => c.slug); - const allStepSlugs = solItems.flatMap((s) => (s.steps ?? []).map((st) => st.capabilitySlug)); - const allSlugsNeeded = [...new Set([...allCapSlugs, ...allStepSlugs])]; + // Batch-fetch persisted SQS scores and test counts + const allSlugsNeeded = capItems.map((c) => c.slug); const [persistedRows, testCountRows] = await Promise.all([ // Query 1: Persisted SQS scores from capabilities table @@ -364,67 +273,23 @@ async function loadCatalog(): Promise { // Assign trust summaries from batch data for (const item of allItems) { try { - if (item.type === "capability") { - const persisted = sqsMap.get(item.slug); - const tests = testCountMap.get(item.slug); - const sqs = persisted?.matrixSqs ? parseFloat(persisted.matrixSqs) : 0; - const sr = persisted?.successRate ? parseFloat(persisted.successRate) : null; - const { badge, badge_label } = determineBadge(tests?.total ?? 0, 0, sr); - - item.trustSummary = { - badge, - badge_label, - avg_response_time_ms: persisted?.avgLatencyMs ?? null, - tests_passing: tests?.passed ?? 0, - tests_total: tests?.total ?? 0, - last_tested_at: tests?.last_tested_at ?? null, - data_source: "internal_testing", - sqs, - sqs_label: sqs > 0 ? sharedSqsLabel(sqs) : "Pending", - }; - } else { - // Solution: aggregate from step capabilities - const stepSlugs = (item.steps ?? []).map((s) => s.capabilitySlug); - let totalPassed = 0; - let totalTests = 0; - let lastTestedAt: string | null = null; - const stepScores: number[] = []; - - for (const ss of stepSlugs) { - const tests = testCountMap.get(ss); - if (tests) { - totalPassed += tests.passed; - totalTests += tests.total; - if (tests.last_tested_at) { - if (!lastTestedAt || tests.last_tested_at > lastTestedAt) { - lastTestedAt = tests.last_tested_at; - } - } - } - const persisted = sqsMap.get(ss); - stepScores.push(persisted?.matrixSqs ? parseFloat(persisted.matrixSqs) : 0); - } - - // Solution SQS: floor-aware — cannot exceed lowest step + 20 - let solSqs = 0; - if (stepScores.length > 0 && stepScores.every((s) => s > 0)) { - solSqs = computeSolutionScore(stepScores); - } - - const { badge, badge_label } = determineBadge(totalTests, 0, null); - - item.trustSummary = { - badge, - badge_label, - avg_response_time_ms: null, - tests_passing: totalPassed, - tests_total: totalTests, - last_tested_at: lastTestedAt, - data_source: "internal_testing", - sqs: solSqs, - sqs_label: solSqs > 0 ? sharedSqsLabel(solSqs) : "Pending", - }; - } + const persisted = sqsMap.get(item.slug); + const tests = testCountMap.get(item.slug); + const sqs = persisted?.matrixSqs ? parseFloat(persisted.matrixSqs) : 0; + const sr = persisted?.successRate ? parseFloat(persisted.successRate) : null; + const { badge, badge_label } = determineBadge(tests?.total ?? 0, 0, sr); + + item.trustSummary = { + badge, + badge_label, + avg_response_time_ms: persisted?.avgLatencyMs ?? null, + tests_passing: tests?.passed ?? 0, + tests_total: tests?.total ?? 0, + last_tested_at: tests?.last_tested_at ?? null, + data_source: "internal_testing", + sqs, + sqs_label: sqs > 0 ? sharedSqsLabel(sqs) : "Pending", + }; } catch (err) { logWarn("suggest-trust-load-failed", "failed to load trust data", { slug: item.slug, @@ -445,7 +310,6 @@ async function loadCatalog(): Promise { log.info( { label: "suggest-catalog-loaded", - solutions: solItems.length, capabilities: capItems.length, use_embeddings: true, }, @@ -552,9 +416,6 @@ export async function typeahead( } } - // Solutions-first: +3 bonus - if (item.type === "solution" && score > 0) score += 3; - // Geography boost: +1 if geo param matches if (geo && score > 0 && item.geography) { const geoUpper = geo.toUpperCase(); @@ -574,37 +435,10 @@ export async function typeahead( scored.sort((a, b) => b.score - a.score); - // Deduplicate country-variant solutions (e.g., "KYB Essentials — Sweden", - // "KYB Essentials — Norway"). Keep highest-scored variant per base name, - // add also_available_for with the collapsed country names. - const deduped: typeof scored = []; - const solutionGroupBest = new Map(); // base name → index in deduped - - for (const entry of scored) { - if (entry.item.type === "solution") { - // Split on " — " or " – " to find base name and variant suffix - const dashIdx = entry.item.name.search(/\s[—–]\s/); - if (dashIdx > 0) { - const baseName = entry.item.name.slice(0, dashIdx); - const variant = entry.item.name.slice(dashIdx).replace(/^\s[—–]\s/, ""); - const existingIdx = solutionGroupBest.get(baseName); - if (existingIdx != null) { - // Already have a higher-scored variant — just add this one's name - const existing = deduped[existingIdx]; - if (!existing._alsoAvailable) existing._alsoAvailable = []; - existing._alsoAvailable.push(variant); - continue; - } - solutionGroupBest.set(baseName, deduped.length); - } - } - deduped.push(entry); - } - - const total = deduped.length; - const topItems = deduped.slice(0, limit); + const total = scored.length; + const topItems = scored.slice(0, limit); - const results: TypeaheadResult[] = topItems.map(({ item, snippet, _alsoAvailable }) => { + const results: TypeaheadResult[] = topItems.map(({ item, snippet }) => { const result: TypeaheadResult = { type: item.type, slug: item.slug, @@ -612,21 +446,15 @@ export async function typeahead( description: item.description, category: item.category, // DEC-20260304-A: price_cents MUST be null for capabilities - price_cents: item.type === "solution" ? item.priceCents : null, + price_cents: null, geography: item.geography, sqs: item.trustSummary?.sqs ?? null, sqs_label: item.trustSummary?.sqs_label ?? null, is_free_tier: item.isFreeTier || undefined, }; - if (item.type === "solution" && item.stepCount) { - result.step_count = item.stepCount; - } if (snippet) { result.match_snippet = snippet; } - if (_alsoAvailable && _alsoAvailable.length > 0) { - result.also_available_for = _alsoAvailable; - } return result; }); @@ -712,16 +540,10 @@ async function rerankWithClaude( const candidateDescriptions = candidates .map(({ item, similarity }, i) => { - let desc = `[${i}] ${item.type.toUpperCase()}: "${item.name}" (slug: ${item.slug})`; + let desc = `[${i}] CAPABILITY: "${item.name}" (slug: ${item.slug})`; desc += `\n ${item.description}`; desc += `\n Category: ${item.category}, Price: €${(item.priceCents / 100).toFixed(2)}`; if (item.geography) desc += `, Geography: ${item.geography}`; - if (item.steps && item.steps.length > 0) { - desc += `\n Steps: ${item.steps.map((s) => s.name).join(" → ")}`; - } - if (item.partOfSolutions && item.partOfSolutions.length > 0) { - desc += `\n Part of solutions: ${item.partOfSolutions.map((s) => s.name).join(", ")}`; - } desc += `\n Semantic similarity: ${similarity.toFixed(3)}`; return desc; }) @@ -734,7 +556,7 @@ async function rerankWithClaude( messages: [ { role: "user", - content: `You are the recommendation engine for Strale, a marketplace of API capabilities for AI agents. + content: `You are the recommendation engine for Strale, a marketplace of atomic API capabilities for AI agents. A developer searched for: "${query}" @@ -743,39 +565,22 @@ Here are the top ${candidates.length} semantic matches from our catalog: ${candidateDescriptions} Your job: -1. Pick the SINGLE best match for the developer's intent. Prefer solutions over individual capabilities when the developer's query implies a multi-step workflow. But if they clearly want a single specific function, prefer the matching capability. -2. Pick up to ${limit} alternatives. Rules: - - If the best match is a solution, alternatives can be other matching solutions OR individual capabilities that are NOT components of the best match. - - If the best match is a capability that is part of a solution, include that solution as an alternative (upsell). - - Never include capabilities that are steps within the recommended solution. +1. Pick the SINGLE best match for the developer's intent. +2. Pick up to ${limit} alternatives — capabilities that also plausibly answer the query. 3. For each pick, write a one-sentence match_reason explaining WHY it fits the developer's query — do NOT repeat the item's description. Bad: "Verifies a Swedish company's identity and registration details" (that's just the description). - Good: "Best match for Swedish company verification — bundles company data, VAT, and sanctions in one call". - The match_reason must reference the user's query and explain the fit, not describe what the item does. + Good: "Best match for Swedish company verification — looks up the company by name in the registry". + The match_reason must reference the user's query and explain the fit. 4. Rephrase the developer's query into a clean, concise label (query_understood_as). 5. CRITICAL: Set total_relevant to 0 if NONE of the candidates actually match the developer's intent. Semantic similarity alone is not enough — the candidate must functionally do what the developer is asking for. -Here are examples of good output: - -Example 1 — Clear solution match: -Query: "check if a swedish company exists" -Candidates: [0: Nordic KYC — Sweden (solution), 1: Swedish Company Data (capability), 2: VAT Validate (capability)] -Good output: -{"best_index": 0, "best_match_reason": "Combines company lookup, VAT validation, and sanctions screening into one compliance check for Swedish companies", "alternatives": [{"index": 1, "match_reason": "Individual company data lookup if you only need basic company info without full verification"}], "query_understood_as": "Swedish company verification", "total_relevant": 2} - -Example 2 — No relevant match (veto): +Example — No relevant match (veto): Query: "translate my website to french" Candidates: [0: Website Carbon Estimate (capability, similarity 0.31), 1: OG Image Check (capability, similarity 0.30)] Good output: {"best_index": 0, "best_match_reason": "", "alternatives": [], "query_understood_as": "Website translation to French", "total_relevant": 0} Note: total_relevant is 0 because none of the candidates actually do translation. The system will return null. -Example 3 — Capability that's part of a solution (upsell): -Query: "dns records for a domain" -Candidates: [0: DNS Lookup (capability), 1: Domain Intelligence (solution containing DNS Lookup), 2: SSL Certificate Check (capability)] -Good output: -{"best_index": 0, "best_match_reason": "Queries A, AAAA, MX, TXT, CNAME, and NS records for any domain", "alternatives": [{"index": 1, "match_reason": "Full domain analysis including DNS, SSL, WHOIS, and reputation — use if you need more than just DNS records"}], "query_understood_as": "DNS record lookup", "total_relevant": 2} - Return ONLY valid JSON: { "best_index": , @@ -855,33 +660,15 @@ function fallbackRanking( candidates: Array<{ item: CatalogItem; similarity: number }>, limit: number, ): SuggestResponse { - const reranked = candidates.map(({ item, similarity }) => { - let score = similarity; - if (item.type === "solution" && similarity > 0.3) score += 0.03; - return { item, score }; - }); - reranked.sort((a, b) => b.score - a.score); - - const best = reranked[0].item; + const best = candidates[0].item; const recommendation = buildRecommendation( best, best.description.split(".")[0], ); - const primaryComponentSlugs = new Set( - best.type === "solution" - ? (best.steps ?? []).map((s) => s.capabilitySlug) - : [], - ); - const alternatives: SuggestRecommendation[] = []; - for (const { item } of reranked.slice(1)) { + for (const { item } of candidates.slice(1)) { if (alternatives.length >= limit) break; - if ( - item.type === "capability" && - primaryComponentSlugs.has(item.slug) - ) - continue; alternatives.push( buildRecommendation(item, item.description.split(".")[0]), ); @@ -920,7 +707,6 @@ function suggestKeyword( if (item.tokens.has(token)) score++; } if (queryTokens.has(item.slug)) score += 2; - if (item.type === "solution" && score > 0) score += 3; if (score > 0) scored.push({ item, score }); } @@ -942,43 +728,14 @@ function suggestKeyword( best.description.split(".")[0], ); - const excludedSlugs = new Set(); - if (best.type === "solution") { - for (const step of best.steps ?? []) { - excludedSlugs.add(step.capabilitySlug); - } - } - const alternatives: SuggestRecommendation[] = []; for (const { item } of scored.slice(1)) { if (alternatives.length >= limit) break; - if ( - item.type === "capability" && - excludedSlugs.has(item.slug) - ) - continue; alternatives.push( buildRecommendation(item, item.description.split(".")[0]), ); } - if (best.type === "capability" && best.partOfSolutions?.length) { - const parentSlug = best.partOfSolutions[0].slug; - if (!alternatives.some((a) => a.slug === parentSlug)) { - const parentItem = items.find( - (i) => i.type === "solution" && i.slug === parentSlug, - ); - if (parentItem && alternatives.length < limit) { - alternatives.push( - buildRecommendation( - parentItem, - parentItem.description.split(".")[0], - ), - ); - } - } - } - return { recommendation, alternatives, @@ -1000,34 +757,9 @@ function buildRecommendation( description: item.description, match_reason: matchReason, price_cents: item.priceCents, + category: item.category, }; - if (item.type === "solution") { - rec.steps = (item.steps ?? []).map((s) => ({ - name: s.name, - capability_slug: s.capabilitySlug, - parallel_group: s.parallelGroup, - })); - rec.step_count = item.stepCount; - rec.geography = item.geography ?? undefined; - rec.badge = "strale_tested"; - } - - if (item.type === "capability") { - rec.category = item.category; - if (item.partOfSolutions && item.partOfSolutions.length > 0) { - const parent = item.partOfSolutions[0]; - rec.part_of_solution = { - slug: parent.slug, - name: parent.name, - price_cents: parent.priceCents, - extra_description: parent.otherCapabilityNames.length > 0 - ? `also includes ${parent.otherCapabilityNames.join(", ")}` - : "", - }; - } - } - if (item.trustSummary) { rec.trust = item.trustSummary; } diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 4af702fb..7c867d28 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -305,74 +305,6 @@ export const openApiSpec = { }, }, - // ─── Web3 Assurance ──────────────────────────────────────────────── - "/v1/web3-assurance": { - post: { - tags: ["web3-assurance"], - summary: "Decision-ready answer about an on-chain counterparty", - description: - "Sister product to Payee Assurance for on-chain targets (wallet, contract, token, DeFi protocol, bridge). Returns a verdict (proceed/review/block/insufficient_evidence), reason_codes (UPPERCASE_SNAKE_CASE), critical_flags, suggested_action, evidence map, and a sidecar audit_url. " + - "Two modes: `outbound` (agent vetting recipient pre-payment, full evaluator set, 8s budget — default) and `reverse-call` (x402 service publisher gating an inbound buyer in real-time, critical evaluators only, sub-second SLA).", - security: [{ BearerAuth: [] }, {}], - "x-ratelimit": { limit: 10, window: "1s", scope: "per API key" }, - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object" as const, - required: ["target"], - properties: { - target: { type: "string" as const, description: "Wallet address (0x... or Solana), contract, token, protocol slug, or domain.", example: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" }, - target_type: { type: "string" as const, enum: ["wallet", "contract", "token", "protocol", "bridge", "domain"] }, - chain: { type: "string" as const, example: "ethereum" }, - action: { type: "string" as const, enum: ["send_payment", "swap", "stake", "mint", "interact", "bridge"] }, - amount_usd: { type: "number" as const }, - mode: { type: "string" as const, enum: ["outbound", "reverse-call"], default: "outbound" }, - agent_id: { type: "string" as const, description: "Optional ERC-8004 agent identifier." }, - caller_jurisdiction: { type: "string" as const, description: "Optional ISO country code for jurisdiction-aware verdict (US, EU, UK, etc.)." }, - }, - }, - }, - }, - }, - responses: { - "200": { - description: "Decision-ready answer.", - content: { - "application/json": { - schema: { - type: "object" as const, - properties: { - target: { type: "string" as const }, - target_type: { type: "string" as const }, - chain: { type: "string" as const }, - mode: { type: "string" as const, enum: ["outbound", "reverse-call"] }, - verdict: { type: "string" as const, enum: ["proceed", "review", "block", "insufficient_evidence"] }, - reason_codes: { type: "array" as const, items: { type: "string" as const, example: "MIXER_DELISTED_ELEVATED" } }, - confidence: { type: "number" as const, minimum: 0, maximum: 1 }, - evidence_completeness: { type: "string" as const, enum: ["complete", "partial", "minimal"] }, - evidence_status: { type: "string" as const, enum: ["corroborated", "partial", "contradictory", "single_source", "minimal"] }, - critical_flags: { type: "array" as const, items: { type: "string" as const } }, - suggested_action: { type: "string" as const }, - expires_at: { type: "string" as const, format: "date-time" }, - evidence: { type: "object" as const, additionalProperties: true }, - source_quality: { type: "array" as const, items: { type: "object" as const, properties: { source: { type: "string" as const }, ms: { type: "integer" as const }, ok: { type: "boolean" as const } } } }, - audit_url: { type: "string" as const, format: "uri" }, - sla: { type: "object" as const, properties: { mode: { type: "string" as const }, p99_ms: { type: "integer" as const }, p50_ms: { type: "integer" as const } } }, - meta: { type: "object" as const, properties: { api_version: { type: "string" as const }, fetched_at: { type: "string" as const, format: "date-time" }, response_ms: { type: "integer" as const } } }, - }, - }, - }, - }, - }, - "400": errorResponse("invalid_request", "Missing or invalid target / mode."), - "401": errorResponse("unauthorized", "Authentication required."), - "429": errorResponse("rate_limited", "Rate limit exceeded."), - }, - }, - }, - // ─── Auth ────────────────────────────────────────────────────────── "/v1/auth/register": { post: { diff --git a/apps/api/src/routes/a2a.ts b/apps/api/src/routes/a2a.ts index a656d0ca..b812c716 100644 --- a/apps/api/src/routes/a2a.ts +++ b/apps/api/src/routes/a2a.ts @@ -13,7 +13,7 @@ import { Hono } from "hono"; import { eq, and, isNull } from "drizzle-orm"; import { createHash, timingSafeEqual } from "node:crypto"; import { getDb } from "../db/index.js"; -import { capabilities, solutions, transactions, users } from "../db/schema.js"; +import { capabilities, transactions, users } from "../db/schema.js"; import { hashApiKey, getKeyPrefix } from "../lib/auth.js"; import { suggest } from "../lib/suggest.js"; import { computePlatformFacts } from "../lib/platform-facts.js"; @@ -106,18 +106,6 @@ async function buildAgentCard(): Promise<{ card: object; etag: string }> { .from(capabilities) .where(eq(capabilities.isActive, true)); - // Fetch active solutions - const solRows = await db - .select({ - slug: solutions.slug, - name: solutions.name, - description: solutions.description, - category: solutions.category, - priceCents: solutions.priceCents, - }) - .from(solutions) - .where(eq(solutions.isActive, true)); - // Build capability skills const capSkills = capRows.map((cap) => { const sqs = cap.matrixSqs ? parseFloat(String(cap.matrixSqs)) : 0; @@ -132,51 +120,7 @@ async function buildAgentCard(): Promise<{ card: object; etag: string }> { }; }); - // Build solution skills - const solSkills = solRows.map((sol) => ({ - id: `solution-${sol.slug}`, - name: sol.name, - description: sol.description, - tags: ["solution", sol.category], - examples: [sol.description.split(/\.\s/)[0].replace(/\.$/, "")], - })); - - const productSkills = [ - { - id: "product-web3-assurance", - name: "Web3 Assurance", - description: - "Decision-ready answer about an on-chain counterparty (wallet, contract, token, DeFi protocol, or bridge) in a single call. Returns verdict (proceed/review/block/insufficient_evidence), reason_codes (UPPERCASE_SNAKE_CASE), critical_flags, suggested_action, evidence map (sanctions, mixer-graded, scam-cluster, wallet-history, token-safety, contract-verification, protocol-risk, audit firms, EAS, ERC-8004, more), and a sidecar audit_url. Two modes: outbound (agent vetting recipient pre-payment, 8s budget) or reverse-call (x402 service publisher gating an inbound buyer, sub-second SLA).", - tags: [ - "web3", - "defi", - "blockchain", - "crypto", - "x402", - "agent-economy", - "counterparty", - "decision-ready", - "verdict", - "reason-codes", - "audit-trail", - "sanctions", - "mixer-graded", - "tornado-cash", - "ofac", - "mica", - "compliance", - ], - examples: [ - "Vet a wallet before sending USDC", - "Check a token contract for honeypot or rug pattern before swapping", - "Pre-trade simulation of a DeFi interaction", - "Gate an inbound x402 buyer before delivering service", - "Verify a DeFi protocol's audit history and recent exploits", - ], - }, - ]; - - const skills = [...productSkills, ...capSkills, ...solSkills]; + const skills = capSkills; // Cert-audit Y-2: capability count and country count are computed from // PLATFORM_FACTS rather than hardcoded. Hardcoding "250+" / "27 countries" diff --git a/apps/api/src/routes/capabilities.ts b/apps/api/src/routes/capabilities.ts index e07749a2..9148f02d 100644 --- a/apps/api/src/routes/capabilities.ts +++ b/apps/api/src/routes/capabilities.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; -import { eq, and, sql, inArray } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import { getDb } from "../db/index.js"; -import { capabilities, solutions, solutionSteps } from "../db/schema.js"; +import { capabilities } from "../db/schema.js"; import { apiError } from "../lib/errors.js"; import { authMiddleware } from "../lib/middleware.js"; import { sqsLabel, gradeFromScore } from "../lib/trust-labels.js"; @@ -134,49 +134,5 @@ capabilitiesRoute.get("/:slug", async (c) => { ); } - // Reverse lookup: which solutions include this capability? - const parentSolutions = await db - .selectDistinct({ - slug: solutions.slug, - name: solutions.name, - description: solutions.description, - priceCents: solutions.priceCents, - category: solutions.category, - geography: solutions.geography, - }) - .from(solutions) - .innerJoin(solutionSteps, eq(solutionSteps.solutionId, solutions.id)) - .where( - and( - eq(solutionSteps.capabilitySlug, slug), - eq(solutions.isActive, true), - ), - ); - - // Batch step counts for all parent solutions in one query - const solSlugs = parentSolutions.map((s) => s.slug); - const stepCounts = solSlugs.length > 0 - ? await db - .select({ - slug: solutions.slug, - count: sql`count(*)`, - }) - .from(solutionSteps) - .innerJoin(solutions, eq(solutionSteps.solutionId, solutions.id)) - .where(inArray(solutions.slug, solSlugs)) - .groupBy(solutions.slug) - : []; - const stepCountMap = new Map(stepCounts.map((r) => [r.slug, Number(r.count)])); - - const partOfSolutions = parentSolutions.map((sol) => ({ - slug: sol.slug, - name: sol.name, - description: sol.description, - price_cents: sol.priceCents, - category: sol.category, - geography: sol.geography, - step_count: stepCountMap.get(sol.slug) ?? 0, - })); - - return c.json({ ...cap, partOfSolutions }); + return c.json(cap); }); diff --git a/apps/api/src/routes/llms-txt.ts b/apps/api/src/routes/llms-txt.ts index 11bd7244..cbe5d554 100644 --- a/apps/api/src/routes/llms-txt.ts +++ b/apps/api/src/routes/llms-txt.ts @@ -47,39 +47,13 @@ Strale provides business data capabilities that AI agents can use at runtime via ## Web3 and DeFi Agent Capabilities -17 Web3-specific capabilities and 9 solutions for on-chain agents. All payable via x402 (USDC on Base) or standard API key. +Web3-specific atomic capabilities for on-chain agents, all payable via x402 (USDC on Base) or standard API key. Discover the live set at GET /v1/capabilities (filter by category=web3) — counts and slugs change as the catalog grows. Wallet security: wallet-risk-score, approval-security-check, wallet-age-check, wallet-balance-lookup, wallet-transactions-lookup. Token and contract safety: token-security-check, contract-verify-check, phishing-site-check. DeFi intelligence: protocol-tvl-lookup, protocol-fees-lookup, stablecoin-flow-check, fear-greed-index, gas-price-check. Identity and compliance: ens-resolve, ens-reverse-lookup, vasp-verify, vasp-non-compliant-check. -Pre-built solutions (single-call multi-step workflows): -- web3-counterparty-dd: wallet risk + age + ENS + sanctions + PEP + adverse media ($0.12) -- web3-token-safety: contract security + verification + deployer risk + domain ($0.05) -- web3-pre-tx-gate: go/no-go middleware for DeFi agents ($0.12) -- web3-dapp-trust: phishing detection + domain intelligence ($0.05) -- web3-protocol-health: TVL + fees + stablecoins + domain trust ($0.05) -- web3-pre-trade: price + security + TVL + sentiment + gas ($0.08) -- web3-wallet-snapshot: balance + transactions + age + ENS + price ($0.05) -- web3-vasp-check: EU MiCA VASP verification + sanctions ($0.08) -- web3-wallet-identity: ENS reverse + risk + age + balance ($0.08) - -## Web3 Assurance (decision-ready on-chain counterparty answer) - -For agents that transact on-chain (send value, swap, stake, mint, bridge, or call a contract), Strale ships Web3 Assurance — a single call that returns a verdict (proceed / review / block / insufficient_evidence) plus machine-parsable reason_codes, critical_flags, suggested_action, evidence map, and a sidecar audit_url. Sister product to Payee Assurance (off-chain KYB). - -Two modes: -- outbound: agent vetting recipient pre-payment. Full evaluator set, 8s budget. Default. -- reverse-call: x402 service publisher gating an inbound buyer in real-time. Critical evaluators only, sub-second SLA. - -20 evaluators behind one call: sanctions (multi-jurisdiction), mixer-graded scoring (Tornado-Cash-graded per OFAC March 2025 precedent — graded, not binary), scam-cluster cross-reference (ScamSniffer), wallet-history risk (GoPlus address security), token safety (GoPlus token security), contract verification (Etherscan + Sourcify), approval inventory (drainer cross-reference), protocol risk (DefiLlama TVL + hacks DB + governance), bridge legitimacy (DefiLlama + L2Beat), audit-firm cross-reference (Certik/OZ/Trail of Bits/Cyfrin/Sherlock/Code4rena), pre-trade simulation (Tenderly), EAS attestations, ERC-8004 reputation, sister-rug detector, REKT Database, Web3 Antivirus. - -Endpoint: POST https://api.strale.io/v1/web3-assurance -MCP tool: strale_web3_assurance -Drop-in middleware: Hono, Express, FastAPI, LangGraph, Coinbase AgentKit -Pricing: $0.03 USDC/call standard via x402 + Stripe annual commits - ## Quick Start ### MCP (recommended for Claude, Cursor, Windsurf) diff --git a/apps/api/src/routes/mcp-server-card.ts b/apps/api/src/routes/mcp-server-card.ts index 296d5281..cc2d7340 100644 --- a/apps/api/src/routes/mcp-server-card.ts +++ b/apps/api/src/routes/mcp-server-card.ts @@ -48,19 +48,12 @@ async function buildCard(): Promise { tools: "dynamic", products: [ { - name: "Payee Assurance", + name: "Counterparty Assurance", description: - "Decision-ready off-chain counterparty answer (KYB / sanctions / UBO / IBAN-name match) in a single auditable call.", + "Decision-ready counterparty answer (KYB / sanctions / UBO / IBAN-name match) in a single auditable call. Available via the atomic capability catalog and the strale_execute MCP tool while the bundled product surface is built.", endpoint: "https://api.strale.io/v1/do", mcp_tool: "strale_execute", }, - { - name: "Web3 Assurance", - description: - "Decision-ready on-chain counterparty answer (wallet / contract / token / protocol / bridge). Verdict + reason_codes + sidecar audit_url. Outbound + reverse-call modes; sub-second SLA in reverse-call.", - endpoint: "https://api.strale.io/v1/web3-assurance", - mcp_tool: "strale_web3_assurance", - }, ], resources: [], prompts: [], diff --git a/apps/api/src/routes/solution-execute.test.ts b/apps/api/src/routes/solution-execute.test.ts deleted file mode 100644 index 7ebb2b0f..00000000 --- a/apps/api/src/routes/solution-execute.test.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Tests for solution execution endpoint logic. - * - * Since there's no endpoint test harness in this repo, these tests cover - * the pure logic extracted from the endpoint handler: status computation, - * refund decisions, and response shaping. - * - * The actual HTTP layer (auth, routing, Hono middleware) is not tested here - * and will be verified in manual smoke tests during Phase 1.4. - */ - -import { describe, it, expect } from "vitest"; - -// ── Status computation logic (extracted from solution-execute.ts) ──────── - -function computeSolutionStatus( - stepCount: number, - errorCount: number, -): "completed" | "partial" | "failed" { - const successCount = stepCount - errorCount; - if (errorCount === 0) return "completed"; - if (successCount > 0) return "partial"; - return "failed"; -} - -function shouldRefund(status: "completed" | "partial" | "failed"): boolean { - return status === "failed"; -} - -function computeFinalBalance( - originalBalance: number, - priceCents: number, - status: "completed" | "partial" | "failed", -): number { - if (status === "failed") return originalBalance; // refunded - return originalBalance - priceCents; // debited -} - -function computeChargedPrice( - priceCents: number, - status: "completed" | "partial" | "failed", -): number { - return status === "failed" ? 0 : priceCents; -} - -// ── Status computation ────────────────────────────────────────────────── - -describe("computeSolutionStatus", () => { - it("returns completed when all steps succeed", () => { - expect(computeSolutionStatus(4, 0)).toBe("completed"); - }); - - it("returns partial when some steps fail", () => { - expect(computeSolutionStatus(4, 1)).toBe("partial"); - expect(computeSolutionStatus(4, 2)).toBe("partial"); - expect(computeSolutionStatus(4, 3)).toBe("partial"); - }); - - it("returns failed when all steps error", () => { - expect(computeSolutionStatus(4, 4)).toBe("failed"); - }); - - it("returns completed for single step with no errors", () => { - expect(computeSolutionStatus(1, 0)).toBe("completed"); - }); - - it("returns failed for single step with error", () => { - expect(computeSolutionStatus(1, 1)).toBe("failed"); - }); -}); - -// ── Refund decision ───────────────────────────────────────────────────── - -describe("shouldRefund", () => { - it("refunds on full failure", () => { - expect(shouldRefund("failed")).toBe(true); - }); - - it("does NOT refund on partial success", () => { - expect(shouldRefund("partial")).toBe(false); - }); - - it("does NOT refund on completed", () => { - expect(shouldRefund("completed")).toBe(false); - }); -}); - -// ── Final balance computation ─────────────────────────────────────────── - -describe("computeFinalBalance", () => { - it("returns original balance on full failure (refund)", () => { - expect(computeFinalBalance(200, 150, "failed")).toBe(200); - }); - - it("returns debited balance on completed", () => { - expect(computeFinalBalance(200, 150, "completed")).toBe(50); - }); - - it("returns debited balance on partial (no refund for partial)", () => { - expect(computeFinalBalance(200, 150, "partial")).toBe(50); - }); -}); - -// ── Charged price computation ─────────────────────────────────────────── - -describe("computeChargedPrice", () => { - it("charges full price on completed", () => { - expect(computeChargedPrice(150, "completed")).toBe(150); - }); - - it("charges full price on partial (no discount for partial)", () => { - expect(computeChargedPrice(150, "partial")).toBe(150); - }); - - it("charges zero on full failure (refunded)", () => { - expect(computeChargedPrice(150, "failed")).toBe(0); - }); -}); - -// ── Price cap validation ──────────────────────────────────────────────── - -describe("price cap validation", () => { - function priceExceedsBudget( - priceCents: number, - maxPriceCents: number | undefined, - ): boolean { - if (maxPriceCents === undefined) return false; - return priceCents > maxPriceCents; - } - - it("passes when max_price_cents is not provided", () => { - expect(priceExceedsBudget(150, undefined)).toBe(false); - }); - - it("passes when price fits within budget", () => { - expect(priceExceedsBudget(150, 500)).toBe(false); - expect(priceExceedsBudget(150, 150)).toBe(false); - }); - - it("fails when price exceeds budget", () => { - expect(priceExceedsBudget(150, 50)).toBe(true); - expect(priceExceedsBudget(150, 149)).toBe(true); - }); -}); - -// ── Response shape assertions ─────────────────────────────────────────── - -describe("response shape", () => { - function buildResponse( - sol: { slug: string; priceCents: number }, - execResult: { steps: Record; errors: string[]; step_count: number; latency_ms: number }, - walletBalanceBefore: number, - ) { - const totalSteps = execResult.step_count; - const errorCount = execResult.errors.length; - const status = computeSolutionStatus(totalSteps, errorCount); - const finalBalance = computeFinalBalance(walletBalanceBefore, sol.priceCents, status); - const chargedPrice = computeChargedPrice(sol.priceCents, status); - - return { - result: { - solution_slug: sol.slug, - status, - steps: execResult.steps, - errors: execResult.errors.length > 0 ? execResult.errors : undefined, - step_count: totalSteps, - latency_ms: execResult.latency_ms, - price_cents: chargedPrice, - wallet_balance_cents: finalBalance, - }, - meta: { - solution_used: sol.slug, - price_cents: chargedPrice, - latency_ms: execResult.latency_ms, - wallet_balance_cents: finalBalance, - }, - }; - } - - it("builds correct completed response", () => { - const resp = buildResponse( - { slug: "kyb-essentials-se", priceCents: 150 }, - { - steps: { - "swedish-company-data": { company_name: "Spotify AB" }, - "vat-validate": { valid: true }, - "sanctions-check": { clear: true }, - "lei-lookup": { found: false }, - }, - errors: [], - step_count: 4, - latency_ms: 4231, - }, - 200, - ); - - expect(resp.result.status).toBe("completed"); - expect(resp.result.solution_slug).toBe("kyb-essentials-se"); - expect(Object.keys(resp.result.steps)).toHaveLength(4); - expect(resp.result.errors).toBeUndefined(); - expect(resp.result.price_cents).toBe(150); - expect(resp.result.wallet_balance_cents).toBe(50); - expect(resp.meta.price_cents).toBe(150); - }); - - it("builds correct partial response", () => { - const resp = buildResponse( - { slug: "kyb-essentials-se", priceCents: 150 }, - { - steps: { - "swedish-company-data": { company_name: "Spotify AB" }, - "vat-validate": { error: "upstream timeout" }, - }, - errors: ["vat-validate: upstream timeout"], - step_count: 4, - latency_ms: 2100, - }, - 200, - ); - - expect(resp.result.status).toBe("partial"); - expect(resp.result.errors).toHaveLength(1); - expect(resp.result.price_cents).toBe(150); // still charged - expect(resp.result.wallet_balance_cents).toBe(50); // still debited - }); - - it("builds correct failed response with refund", () => { - const resp = buildResponse( - { slug: "kyb-essentials-se", priceCents: 150 }, - { - steps: {}, - errors: [ - "swedish-company-data: timeout", - "vat-validate: timeout", - "sanctions-check: timeout", - "lei-lookup: timeout", - ], - step_count: 4, - latency_ms: 230, - }, - 200, - ); - - expect(resp.result.status).toBe("failed"); - expect(resp.result.errors).toHaveLength(4); - expect(resp.result.price_cents).toBe(0); // refunded - expect(resp.result.wallet_balance_cents).toBe(200); // restored - expect(resp.meta.price_cents).toBe(0); - }); -}); - -// ── F-B-022: phase-2 UPDATE failure handling ───────────────────────────── -// -// When the phase-2 transaction UPDATE fails (DB blip, serialization -// conflict, etc.), the fix must: -// - not wedge the row at status='executing' silently (caller now awaits), -// - refund the wallet IFF !allFailed (the allFailed path already -// refunded at line ~270 before phase-2). -// This mirrors the decision tree implemented in the endpoint. - -function shouldRefundOnPhase2Failure(allFailed: boolean): boolean { - // allFailed=true: refund already happened pre-phase-2 (line ~270). - // allFailed=false: wallet still debited from phase-1 — must refund. - return !allFailed; -} - -describe("F-B-022: phase-2 UPDATE failure handling", () => { - it("refunds when !allFailed (wallet was debited at phase-1)", () => { - expect(shouldRefundOnPhase2Failure(false)).toBe(true); - }); - - it("does not double-refund when allFailed (already refunded pre-phase-2)", () => { - expect(shouldRefundOnPhase2Failure(true)).toBe(false); - }); - - it("finalization-failed response carries wallet_balance_cents for retry guidance", () => { - const errorResponse = { - error_code: "transaction_finalization_failed", - details: { - transaction_id: "fake-uuid", - solution_slug: "kyb-essentials-se", - wallet_balance_cents: 200, - }, - }; - expect(errorResponse.error_code).toBe("transaction_finalization_failed"); - expect(errorResponse.details.wallet_balance_cents).toBe(200); - }); -}); diff --git a/apps/api/src/routes/solution-execute.ts b/apps/api/src/routes/solution-execute.ts index 1588509a..e2185b46 100644 --- a/apps/api/src/routes/solution-execute.ts +++ b/apps/api/src/routes/solution-execute.ts @@ -1,467 +1,24 @@ /** - * POST /v1/solutions/:slug/execute — Execute a bundled solution. + * Retired DEC-20260503-A 2026-05-04. Phase 1b removal: separate to-do. * - * Two-phase transaction write matching /v1/do pattern: - * 1. Insert transaction row at "executing" inside same DB transaction as wallet debit - * 2. Update to "completed" or "failed" after executeSolution() returns - * - * Status vocabulary matches /v1/do: "completed" or "failed". - * Partial successes (some steps failed, caller received value) map to "completed" - * with per-step detail in audit_trail JSONB. - * - * Full failure refunds the wallet. Partial success does NOT refund. - * - * Part of DEC-20260405-A fix plan, phase 1.4. + * POST /v1/solutions/:slug/execute is retired with the rest of the + * public solutions surface. The underlying executor lives in + * lib/solution-executor.ts and is preserved for any future bundled- + * product module that may reuse it. */ import { Hono } from "hono"; -import { eq } from "drizzle-orm"; -import { getDb } from "../db/index.js"; -import { solutions, wallets, walletTransactions, transactions } from "../db/schema.js"; -import { authMiddleware } from "../lib/middleware.js"; -import { rateLimitByKey } from "../lib/rate-limit.js"; -import { apiError } from "../lib/errors.js"; -import { executeSolution } from "../lib/solution-executor.js"; -import { sanitizeFailureReason } from "../lib/sanitize.js"; -import { logError } from "../lib/log.js"; -import { getProcessingJurisdictions } from "../lib/provenance-builder.js"; import type { AppEnv } from "../types.js"; export const solutionExecuteRoute = new Hono(); -solutionExecuteRoute.post( - "/:slug/execute", - authMiddleware, - rateLimitByKey(10, 1000), - async (c) => { - const slug = c.req.param("slug")!; - const user = c.get("user"); - const db = getDb(); - - c.get("log").info( - { label: "solutions-execute-start", solution_slug: slug }, - "solutions-execute-start", - ); - - // ── 1. Parse request body ───────────────────────────────────────── - const body = await c.req.json().catch(() => null); - if (!body || typeof body !== "object") { - return c.json(apiError("invalid_request", "Request body is required."), 400); - } - - const inputs = body.inputs; - if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) { - return c.json( - apiError("invalid_request", "'inputs' is required and must be an object."), - 400, - ); - } - - const maxPriceCents: number | undefined = - typeof body.max_price_cents === "number" && body.max_price_cents > 0 - ? Math.round(body.max_price_cents) - : undefined; - - // ── 2. Look up solution ─────────────────────────────────────────── - const [sol] = await db - .select({ - id: solutions.id, - slug: solutions.slug, - name: solutions.name, - priceCents: solutions.priceCents, - isActive: solutions.isActive, - transparencyTag: solutions.transparencyTag, - }) - .from(solutions) - .where(eq(solutions.slug, slug)) - .limit(1); - - if (!sol || !sol.isActive) { - return c.json( - apiError("not_found", `Solution '${slug}' not found.`), - 404, - ); - } - - // ── 3. Price check (before wallet debit) ────────────────────────── - if (maxPriceCents !== undefined && sol.priceCents > maxPriceCents) { - return c.json( - apiError("budget_exceeded", `Solution '${slug}' costs €${(sol.priceCents / 100).toFixed(2)} which exceeds your max_price_cents of ${maxPriceCents}.`, { - solution_slug: slug, - actual_price_cents: sol.priceCents, - max_price_cents: maxPriceCents, - }), - 402, - ); - } - - // ── 4. Wallet debit + transaction insert (single DB transaction) ── - const startTime = Date.now(); - let transactionId: string; - let balanceAfter: number; - let walletId: string; - let walletBalanceBefore: number; - - try { - const txResult = await db.transaction(async (tx) => { - // Lock wallet row - const [wallet] = await tx - .select() - .from(wallets) - .where(eq(wallets.userId, user.id)) - .for("update"); - - if (!wallet || wallet.balanceCents < sol.priceCents) { - return { - ok: false as const, - balance: wallet?.balanceCents ?? 0, - }; - } - - // Debit wallet - const newBalance = wallet.balanceCents - sol.priceCents; - await tx - .update(wallets) - .set({ balanceCents: newBalance, updatedAt: new Date() }) - .where(eq(wallets.id, wallet.id)); - - // Log wallet transaction - await tx.insert(walletTransactions).values({ - walletId: wallet.id, - amountCents: -sol.priceCents, - type: "purchase", - description: `Solution: ${sol.slug}`, - }); - - // Insert transaction row at "executing" — two-phase write per /v1/do pattern - const [txnRecord] = await tx - .insert(transactions) - .values({ - userId: user.id, - capabilityId: null, - solutionSlug: sol.slug, - status: "executing", - input: inputs as Record, - priceCents: sol.priceCents, - transparencyMarker: sol.transparencyTag ?? "mixed", - // F-AUDIT-01 / CCO #3: previously hardcoded "EU" while running in - // US East. Solutions always include orchestration through Strale's - // own processing region, plus US for any LLM step (Anthropic). - // transparencyTag "mixed" or "ai_generated" → adds US automatically. - dataJurisdiction: - getProcessingJurisdictions("stable_api", sol.transparencyTag ?? "mixed").join(",") || - "unknown", - // CCO P0 #6: solutions can take >10s (the GRACE_MS the retry - // worker uses), so a 'pending' insert would race the worker. - // Insert as 'deferred'; phase-2 UPDATEs flip to 'pending' - // atomically with the final auditTrail/output writes. - complianceHashState: "deferred", - paymentMethod: "wallet", - }) - .returning({ id: transactions.id }); - - return { - ok: true as const, - transactionId: txnRecord.id, - balanceAfter: newBalance, - walletId: wallet.id, - walletBalanceBefore: wallet.balanceCents, - }; - }); - - if (!txResult.ok) { - return c.json( - apiError("insufficient_balance", `Your wallet has €${(txResult.balance / 100).toFixed(2)} but this solution costs €${(sol.priceCents / 100).toFixed(2)}.`, { - wallet_balance_cents: txResult.balance, - required_cents: sol.priceCents, - topup_url: "/v1/wallet/topup", - }), - 402, - ); - } - - transactionId = txResult.transactionId; - balanceAfter = txResult.balanceAfter; - walletId = txResult.walletId; - walletBalanceBefore = txResult.walletBalanceBefore; - } catch (err) { - c.get("log").error( - { label: "solutions-tx-insert-failed", solution_slug: slug, err: err instanceof Error ? { message: err.message, stack: err.stack } : err }, - "solutions-tx-insert-failed", - ); - return c.json( - apiError("execution_failed", "Failed to process payment."), - 500, - ); - } - - // ── 5. Execute solution steps ───────────────────────────────────── - let execResult; - try { - execResult = await executeSolution(sol.id, inputs as Record); - } catch (err) { - const latencyMs = Date.now() - startTime; - const errorMessage = err instanceof Error ? err.message : String(err); - c.get("log").error( - { label: "solutions-execute-error", solution_slug: slug, err: err instanceof Error ? { message: err.message, stack: err.stack } : err }, - "solutions-execute-error", - ); - - // F-B-022: Update transaction to failed. AWAIT so the row reaches a - // terminal state before we respond — a fire-and-forget UPDATE on a DB - // blip would wedge the row at status='executing'. Refund runs - // regardless; the caller is already receiving a failure response. - try { - await db.update(transactions) - .set({ - status: "failed", - error: sanitizeFailureReason(errorMessage), - latencyMs, - completedAt: new Date(), - auditTrail: buildInlineAudit(slug, [], 0, 0, latencyMs, true, c), - // CCO P0 #6: flip 'deferred' → 'pending' atomically with the - // final write so the retry worker hashes the final state. - complianceHashState: "pending", - }) - .where(eq(transactions.id, transactionId)); - } catch (e) { - c.get("log").error( - { label: "solutions-tx-update-failed", transaction_id: transactionId, solution_slug: slug, err: e instanceof Error ? { message: e.message } : e }, - "solutions-tx-update-failed", - ); - } - - // Refund - await refundWallet(db, walletId, walletBalanceBefore, sol.priceCents, sol.slug, "execution error"); - - return c.json( - apiError("execution_failed", "Solution execution failed. You were not charged.", { - transaction_id: transactionId, - solution_slug: slug, - error: sanitizeFailureReason(errorMessage), - wallet_balance_cents: walletBalanceBefore, - }), - 500, - ); - } - - if (!execResult) { - const latencyMs = Date.now() - startTime; - - // F-B-022: await the UPDATE so the row isn't wedged at 'executing' - // if the DB briefly flakes. Refund runs regardless. - try { - await db.update(transactions) - .set({ - status: "failed", - error: "Solution has no steps configured", - latencyMs, - completedAt: new Date(), - auditTrail: buildInlineAudit(slug, [], 0, 0, latencyMs, true, c), - // CCO P0 #6: flip 'deferred' → 'pending' atomically with the - // final write so the retry worker hashes the final state. - complianceHashState: "pending", - }) - .where(eq(transactions.id, transactionId)); - } catch (e) { - c.get("log").error( - { label: "solutions-tx-update-failed", transaction_id: transactionId, solution_slug: slug, err: e instanceof Error ? { message: e.message } : e }, - "solutions-tx-update-failed", - ); - } - - await refundWallet(db, walletId, walletBalanceBefore, sol.priceCents, sol.slug, "no steps configured"); - - return c.json( - apiError("execution_failed", "Solution has no steps configured. You were not charged.", { - transaction_id: transactionId, - solution_slug: slug, - wallet_balance_cents: walletBalanceBefore, - }), - 503, - ); - } - - // ── 6. Determine status (matches /v1/do vocabulary) ─────────────── - const latencyMs = Date.now() - startTime; - const totalSteps = execResult.step_count; - const errorCount = execResult.errors.length; - const stepsSucceeded = totalSteps - errorCount; - const allFailed = stepsSucceeded === 0; - - // /v1/do vocabulary: "completed" or "failed". Partial success maps to "completed" - // with per-step detail in audit_trail. - const txStatus = allFailed ? "failed" : "completed"; - const chargedPrice = allFailed ? 0 : sol.priceCents; - - // Full failure — refund - if (allFailed) { - await refundWallet(db, walletId, walletBalanceBefore, sol.priceCents, sol.slug, "all steps failed"); - } - - const finalBalance = allFailed ? walletBalanceBefore : balanceAfter; - - // Build per-step audit breakdown with per-step latency - const stepAuditEntries = Object.entries(execResult.steps).map(([capSlug, output], index) => { - const isError = execResult.errors.some((e) => e.startsWith(`${capSlug}:`)); - const timing = execResult.stepTimings.find((t) => t.capabilitySlug === capSlug); - return { - index, - capabilitySlug: capSlug, - status: isError ? "failed" : "completed", - latencyMs: timing?.latencyMs ?? 0, - error: isError - ? sanitizeFailureReason(execResult.errors.find((e) => e.startsWith(`${capSlug}:`))?.split(": ").slice(1).join(": ") ?? null) - : null, - }; - }); - - // TODO: extract to buildFullSolutionAudit() once the shape stabilizes - // across multiple solution executions in production. See DEC-20260405-B. - const auditTrail = buildInlineAudit( - slug, stepAuditEntries, stepsSucceeded, errorCount, latencyMs, allFailed, c, - ); - - // ── 7. Update transaction row (phase 2 of two-phase write) ──────── - // F-B-022: AWAIT the phase-2 UPDATE. If it fails, the wallet was debited - // in phase-1 (unless allFailed, in which case we already refunded at - // line ~270). A wedged 'executing' row with a debited wallet is the - // worst outcome — customer paid, no record. Refund on the non-allFailed - // path and return 500 so the caller can retry. - try { - await db.update(transactions) - .set({ - status: txStatus, - output: execResult.steps, - latencyMs, - completedAt: new Date(), - priceCents: chargedPrice, - auditTrail, - // CCO P0 #6: flip 'deferred' → 'pending' atomically with the - // final auditTrail/output write. The retry worker will hash the - // final state on its next tick (≥10s later) — no race possible. - complianceHashState: "pending", - }) - .where(eq(transactions.id, transactionId)); - } catch (e) { - c.get("log").error( - { - label: "solutions-tx-update-failed", - transaction_id: transactionId, - solution_slug: slug, - all_failed: allFailed, - err: e instanceof Error ? { message: e.message, stack: e.stack } : e, - }, - "solutions-tx-update-failed", - ); - - // allFailed path already refunded at line ~270. Non-allFailed means - // wallet is still debited; refund now so the customer isn't left - // paying for a transaction we can't confirm. - if (!allFailed) { - await refundWallet(db, walletId, walletBalanceBefore, sol.priceCents, sol.slug, "phase2 update failed"); - } - - return c.json( - apiError("transaction_finalization_failed", "Solution executed but the transaction record could not be finalized. You were not charged.", { - transaction_id: transactionId, - solution_slug: slug, - wallet_balance_cents: walletBalanceBefore, - }), - 500, - ); - } - - c.get("log").info( - { - label: "solutions-execute-done", - solution_slug: slug, - status: txStatus, - latency_ms: latencyMs, - steps_succeeded: stepsSucceeded, - steps_failed: errorCount, - transaction_id: transactionId, - }, - "solutions-execute-done", - ); - - // ── 8. Build response ───────────────────────────────────────────── - // result.status uses the richer vocabulary for the caller: - // "completed" (all ok), "partial" (some failed), "failed" (all failed) - const responseStatus = allFailed ? "failed" : (errorCount > 0 ? "partial" : "completed"); - - return c.json({ - result: { - transaction_id: transactionId, - solution_slug: sol.slug, - status: responseStatus, - steps: execResult.steps, - errors: execResult.errors.length > 0 ? execResult.errors : undefined, - step_count: totalSteps, - latency_ms: latencyMs, - price_cents: chargedPrice, - wallet_balance_cents: finalBalance, - }, - meta: { - solution_used: sol.slug, - price_cents: chargedPrice, - latency_ms: latencyMs, - wallet_balance_cents: finalBalance, - audit: auditTrail, - }, - }); - }, -); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -async function refundWallet( - db: ReturnType, - walletId: string, - originalBalance: number, - priceCents: number, - solutionSlug: string, - reason: string, -): Promise { - try { - await db - .update(wallets) - .set({ balanceCents: originalBalance, updatedAt: new Date() }) - .where(eq(wallets.id, walletId)); - await db.insert(walletTransactions).values({ - walletId, - amountCents: priceCents, - type: "refund", - description: `Refund: ${solutionSlug} (${reason})`, - }); - } catch (err) { - logError("solutions-refund-failed", err, { solution_slug: solutionSlug, wallet_id: walletId }); - } -} +const goneBody = { + error_code: "gone", + message: + "Solutions surface retired per DEC-20260503-A. Visit https://strale.io for Counterparty Assurance.", + deprecated_at: "2026-05-04", + alternative: "https://strale.io", +}; -function buildInlineAudit( - solutionSlug: string, - steps: Array<{ index: number; capabilitySlug: string; status: string; latencyMs: number; error: string | null }>, - stepsSucceeded: number, - stepsFailed: number, - totalLatencyMs: number, - refunded: boolean, - c: any, -): Record { - // TODO: extract to buildFullSolutionAudit() once the shape stabilizes - // across multiple solution executions in production. See DEC-20260405-B. - return { - requestContext: { - userAgent: c.req.header("user-agent") ?? null, - referer: c.req.header("referer") ?? c.req.header("referrer") ?? null, - origin: c.req.header("origin") ?? null, - timestamp: new Date().toISOString(), - }, - solutionSlug, - steps, - stepsSucceeded, - stepsFailed, - totalLatencyMs, - refunded, - }; -} +// POST /v1/solutions/:slug/execute — retired +solutionExecuteRoute.post("/:slug/execute", (c) => c.json(goneBody, 410)); diff --git a/apps/api/src/routes/solutions.ts b/apps/api/src/routes/solutions.ts index ce768069..2854c6d2 100644 --- a/apps/api/src/routes/solutions.ts +++ b/apps/api/src/routes/solutions.ts @@ -1,249 +1,31 @@ +/** + * Retired DEC-20260503-A 2026-05-04. Phase 1b removal: separate to-do. + * + * The public solutions surface is retired per DEC-20260503-A (dual-domain + * architecture: strale.dev = atomic capabilities, strale.io = bundled + * products). Bundled products (Counterparty Assurance) are being built + * fresh on strale.io and do not reuse the solutions catalog. + * + * All public solutions routes return 410 Gone with a structured + * deprecation body. The route registration is kept in app.ts so the + * 410 is reachable; full handler removal is phase 1b. + */ + import { Hono } from "hono"; -import { eq, and, asc, inArray } from "drizzle-orm"; -import { getDb } from "../db/index.js"; -import { solutions, solutionSteps, capabilities } from "../db/schema.js"; -import { apiError } from "../lib/errors.js"; -import { getRelatedSolutions } from "../lib/related-items.js"; -import { sqsLabel, gradeFromScore, computeSolutionScore, computeSolutionTrend, worstFreshnessLevel, oldestTestedAt } from "../lib/trust-labels.js"; import type { AppEnv } from "../types.js"; -// Solutions are public — no auth required (catalog data, same as capabilities) export const solutionsRoute = new Hono(); -// GET /v1/solutions — List active solutions -solutionsRoute.get("/", async (c) => { - const db = getDb(); - const category = c.req.query("category"); - - const conditions = [eq(solutions.isActive, true)]; - if (category) { - conditions.push(eq(solutions.category, category)); - } - - const rows = await db - .select({ - slug: solutions.slug, - name: solutions.name, - description: solutions.description, - category: solutions.category, - priceCents: solutions.priceCents, - geography: solutions.geography, - transparencyTag: solutions.transparencyTag, - complianceCoverage: solutions.complianceCoverage, - searchTags: solutions.searchTags, - id: solutions.id, - }) - .from(solutions) - .where(and(...conditions)) - .orderBy(asc(solutions.displayOrder)); - - // Batch-fetch ALL steps for ALL solutions in one query (eliminates N+1) - const solIds = rows.map((r) => r.id); - const allSteps = solIds.length > 0 - ? await db - .select({ - solutionId: solutionSteps.solutionId, - capabilitySlug: solutionSteps.capabilitySlug, - matrixSqs: capabilities.matrixSqs, - qpScore: capabilities.qpScore, - rpScore: capabilities.rpScore, - trend: capabilities.trend, - freshnessLevel: capabilities.freshnessLevel, - lastTestedAt: capabilities.lastTestedAt, - guidanceUsable: capabilities.guidanceUsable, - guidanceStrategy: capabilities.guidanceStrategy, - dataSource: capabilities.dataSource, - }) - .from(solutionSteps) - .leftJoin(capabilities, eq(solutionSteps.capabilitySlug, capabilities.slug)) - .where(inArray(solutionSteps.solutionId, solIds)) - .orderBy(solutionSteps.solutionId, asc(solutionSteps.stepOrder)) - : []; - - // Group steps by solution ID - const stepsBySolution = new Map(); - for (const step of allSteps) { - const list = stepsBySolution.get(step.solutionId) ?? []; - list.push(step); - stepsBySolution.set(step.solutionId, list); - } - - const gradeOrder = ["A", "B", "C", "D", "F", "pending"]; - const strategyOrder = ["direct", "retry_with_backoff", "queue_for_later", "unavailable"]; - - const result = rows.map((row) => { - const steps = stepsBySolution.get(row.id) ?? []; - const stepSqs = steps.map((s) => s.matrixSqs ? parseFloat(s.matrixSqs) : 0); - const sqs = computeSolutionScore(stepSqs); - - // Detect pending steps: SQS 0 AND qpScore null (never tested, not degraded) - const hasPendingStep = steps.some((s) => - (!s.matrixSqs || parseFloat(s.matrixSqs) === 0) && s.qpScore === null, - ); - - const worstQuality = steps.reduce((w, s) => { - const g = gradeFromScore(s.qpScore); - return gradeOrder.indexOf(g) > gradeOrder.indexOf(w) ? g : w; - }, "A"); - const worstReliability = steps.reduce((w, s) => { - const g = gradeFromScore(s.rpScore); - return gradeOrder.indexOf(g) > gradeOrder.indexOf(w) ? g : w; - }, "A"); - - const allUsable = steps.every((s) => s.guidanceUsable ?? true); - const worstStrategy = steps.reduce((w, s) => { - const st = s.guidanceStrategy ?? "direct"; - return strategyOrder.indexOf(st) > strategyOrder.indexOf(w) ? st : w; - }, "direct"); - - return { - slug: row.slug, - name: row.name, - description: row.description, - category: row.category, - price_cents: row.priceCents, - step_count: steps.length, - geography: row.geography, - transparency_tag: row.transparencyTag, - compliance_coverage: row.complianceCoverage ?? [], - search_tags: row.searchTags ?? [], - capabilities: steps.map((s) => s.capabilitySlug), - data_sources: [...new Set(steps.map((s) => s.dataSource).filter(Boolean))], - sqs: hasPendingStep ? null : sqs, - sqs_label: hasPendingStep ? "Building track record" : sqsLabel(sqs), - quality: hasPendingStep ? "pending" : worstQuality, - reliability: hasPendingStep ? "pending" : worstReliability, - trend: hasPendingStep ? "stable" : computeSolutionTrend(steps.map((s) => s.trend ?? "stable")), - freshness_level: worstFreshnessLevel(steps.map((s) => s.freshnessLevel ?? "fresh")), - last_tested_at: oldestTestedAt(steps.map((s) => s.lastTestedAt)), - usable: hasPendingStep ? false : allUsable, - strategy: hasPendingStep ? "queue_for_later" : worstStrategy, - pending: hasPendingStep || undefined, - }; - }); - - return c.json({ solutions: result, total: result.length }); -}); - -// GET /v1/solutions/:slug — Full solution detail -solutionsRoute.get("/:slug", async (c) => { - const slug = c.req.param("slug"); - const db = getDb(); - - const [sol] = await db - .select({ - id: solutions.id, - slug: solutions.slug, - name: solutions.name, - marketingName: solutions.marketingName, - description: solutions.description, - longDescription: solutions.longDescription, - agentDescription: solutions.agentDescription, - category: solutions.category, - priceCents: solutions.priceCents, - componentSumCents: solutions.componentSumCents, - valueTier: solutions.valueTier, - geography: solutions.geography, - transparencyTag: solutions.transparencyTag, - targetAudience: solutions.targetAudience, - inputSchema: solutions.inputSchema, - exampleInput: solutions.exampleInput, - exampleOutput: solutions.exampleOutput, - complianceCoverage: solutions.complianceCoverage, - extendsWith: solutions.extendsWith, - }) - .from(solutions) - .where(and(eq(solutions.slug, slug), eq(solutions.isActive, true))) - .limit(1); - - if (!sol) { - return c.json( - apiError("not_found", `Solution '${slug}' not found.`), - 404, - ); - } - - // Get steps with capability details - const steps = await db - .select({ - stepOrder: solutionSteps.stepOrder, - capabilitySlug: solutionSteps.capabilitySlug, - canParallel: solutionSteps.canParallel, - parallelGroup: solutionSteps.parallelGroup, - inputMap: solutionSteps.inputMap, - capabilityName: capabilities.name, - capabilityPriceCents: capabilities.priceCents, - dataSource: capabilities.dataSource, - }) - .from(solutionSteps) - .leftJoin( - capabilities, - eq(solutionSteps.capabilitySlug, capabilities.slug), - ) - .where(eq(solutionSteps.solutionId, sol.id)) - .orderBy(asc(solutionSteps.stepOrder)); - - // Fetch extends_with capabilities - const extendsSlugs = (sol.extendsWith as string[] | null) ?? []; - const extendsCaps = extendsSlugs.length > 0 - ? await db - .select({ - slug: capabilities.slug, - name: capabilities.name, - description: capabilities.description, - priceCents: capabilities.priceCents, - category: capabilities.category, - }) - .from(capabilities) - .where(inArray(capabilities.slug, extendsSlugs)) - : []; +const goneBody = { + error_code: "gone", + message: + "Solutions surface retired per DEC-20260503-A. Visit https://strale.io for Counterparty Assurance.", + deprecated_at: "2026-05-04", + alternative: "https://strale.io", +}; - // Related solutions: smart matching (shared capabilities > same geo > same category) - const related = await getRelatedSolutions(sol.slug, 4); +// GET /v1/solutions — retired +solutionsRoute.get("/", (c) => c.json(goneBody, 410)); - return c.json({ - slug: sol.slug, - name: sol.name, - marketing_name: sol.marketingName, - description: sol.description, - long_description: sol.longDescription ?? null, - agent_description: sol.agentDescription ?? null, - category: sol.category, - price_cents: sol.priceCents, - component_sum_cents: sol.componentSumCents, - value_tier: sol.valueTier, - geography: sol.geography, - transparency_tag: sol.transparencyTag, - target_audience: sol.targetAudience, - input_schema: sol.inputSchema, - example_input: sol.exampleInput, - example_output: sol.exampleOutput, - compliance_coverage: sol.complianceCoverage ?? [], - steps: steps.map((s) => ({ - step_order: s.stepOrder, - capability_slug: s.capabilitySlug, - capability_name: s.capabilityName, - capability_price_cents: s.capabilityPriceCents, - can_parallel: s.canParallel, - parallel_group: s.parallelGroup, - input_map: s.inputMap, - data_source: s.dataSource, - })), - extends_with: extendsCaps.map((cap) => ({ - slug: cap.slug, - name: cap.name, - description: cap.description, - price_cents: cap.priceCents, - category: cap.category, - })), - related_solutions: related.map((r) => ({ - slug: r.slug, - name: r.name, - price_cents: r.price_cents, - category: r.category, - geography: r.geography, - reason: r.reason, - step_count: r.step_count, - })), - }); -}); +// GET /v1/solutions/:slug — retired +solutionsRoute.get("/:slug", (c) => c.json(goneBody, 410)); diff --git a/apps/api/src/routes/x402-gateway-v2.ts b/apps/api/src/routes/x402-gateway-v2.ts index 3dfd5140..957665e8 100644 --- a/apps/api/src/routes/x402-gateway-v2.ts +++ b/apps/api/src/routes/x402-gateway-v2.ts @@ -13,7 +13,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { eq, and, inArray } from "drizzle-orm"; import { getDb } from "../db/index.js"; -import { capabilities, solutions, transactions, x402OrphanSettlements } from "../db/schema.js"; +import { capabilities, transactions, x402OrphanSettlements } from "../db/schema.js"; import { getExecutor } from "../capabilities/index.js"; import { isX402Configured, @@ -29,7 +29,6 @@ import { } from "../lib/x402-gateway.js"; import { declareDiscoveryExtension } from "@x402/extensions/bazaar"; import { sanitizeFailureReason } from "../lib/sanitize.js"; -import { executeSolution } from "../lib/solution-executor.js"; import { logError } from "../lib/log.js"; import { getProcessingJurisdictions } from "../lib/provenance-builder.js"; import { getProcessingLocation } from "../lib/processing-location.js"; @@ -68,22 +67,10 @@ interface X402Capability { personalDataCategories: string[]; } -interface X402Solution { - id: string; - slug: string; - name: string; - description: string; - x402PriceUsd: number; - priceCents: number; - inputSchema: Record | null; - outputSchema: Record | null; -} - // ─── Cache ────────────────────────────────────────────────────────────────── const CACHE_TTL_MS = 60_000; let _capCache: Map = new Map(); -let _solCache: Map = new Map(); let _cacheExpiry = 0; async function ensureCache(): Promise { @@ -148,35 +135,7 @@ async function ensureCache(): Promise { }); } - // Solutions — same derivation rule as capabilities. - const solRows = await db - .select({ - id: solutions.id, - slug: solutions.slug, - name: solutions.name, - description: solutions.description, - priceCents: solutions.priceCents, - inputSchema: solutions.inputSchema, - }) - .from(solutions) - .where(and(eq(solutions.x402Enabled, true), eq(solutions.isActive, true))); - - const newSolCache = new Map(); - for (const row of solRows) { - newSolCache.set(row.slug, { - id: row.id, - slug: row.slug, - name: row.name, - description: row.description ?? "", - x402PriceUsd: eurCentsToUsd(row.priceCents), - priceCents: row.priceCents, - inputSchema: row.inputSchema as Record | null, - outputSchema: null, // solutions table has no output_schema column - }); - } - _capCache = newCapCache; - _solCache = newSolCache; _cacheExpiry = Date.now() + CACHE_TTL_MS; } catch (err) { logError("x402-cache-refresh-failed", err); @@ -777,16 +736,6 @@ x402GatewayV2.get("/catalog", async (c) => { input_schema: cap.inputSchema, })); - const sols = [..._solCache.values()].map((sol) => ({ - slug: sol.slug, - name: sol.name, - description: sol.description, - price_usd: sol.x402PriceUsd, - method: "POST", - endpoint: `${BASE_URL}/x402/solutions/${sol.slug}`, - input_schema: sol.inputSchema, - })); - c.header("Cache-Control", "public, max-age=60"); return c.json({ x402: true, @@ -794,193 +743,29 @@ x402GatewayV2.get("/catalog", async (c) => { facilitator: process.env.X402_FACILITATOR_URL ?? "https://x402.org/facilitator", wallet: WALLET_ADDRESS || null, capabilities: caps, - solutions: sols, - total: caps.length + sols.length, + total: caps.length, }); }); -// ─── Solution execution: /x402/solutions/:slug ────────────────────────────── - -x402GatewayV2.on(["GET", "POST"], "/solutions/:slug", async (c) => { - const slug = c.req.param("slug"); - await ensureCache(); - - const sol = _solCache.get(slug); - if (!sol) { - return c.json( - { error: "Solution not found or not available via x402.", hint: `${BASE_URL}/x402/catalog` }, - 404, - ); - } - - // Payment check FIRST — so Bazaar's empty-body discovery crawl gets a 402 - // (not a 400 from failed JSON parse). See capability handler for detail. - // - // Verify only; defer settlement until the solution has produced at least one - // successful step (DEC-14). If the solution produces no output the caller is - // not charged. - let verified: X402VerifiedPayment | undefined; - let paymentHash: string | null = null; - if (sol.x402PriceUsd > 0) { - const paymentHeader = extractPaymentHeader(c.req.raw.headers); - - if (!paymentHeader) { - if (!isX402Configured()) { - return c.json({ error: "x402 payments not configured on this server." }, 503); - } - const { body } = build402( - sol.name, sol.description, sol.x402PriceUsd, - `${BASE_URL}/x402/solutions/${slug}`, - null, sol.inputSchema, "POST", sol.outputSchema, - ); - // No Payment-Required header: v1 body is the canonical source. Emitting a - // v1-encoded header trips v2-only header decoders (e.g. @agentcash/discovery) - // which never fall back to body parsing once any header is present. - return c.json(body, 402); - } - - if (!isX402Configured()) { - return c.json({ error: "x402 payments not configured." }, 503); - } - - const solRebuild = build402( - sol.name, sol.description, sol.x402PriceUsd, - `${BASE_URL}/x402/solutions/${slug}`, - null, sol.inputSchema, "POST", sol.outputSchema, - ); - const verification = await verifyX402PaymentOnly( - paymentHeader, - sol.priceCents, - sol.x402PriceUsd, - { - resource: solRebuild.paymentRequirement.resource as string, - description: solRebuild.paymentRequirement.description as string, - outputSchema: solRebuild.paymentRequirement.outputSchema as Record, - }, - ); - if (!verification.valid || !verification.verified) { - return c.json({ error: "Payment verification failed", detail: verification.error }, 402); - } - verified = verification.verified; - - // Cert-audit C9: replay dedup. If this exact payment header was already - // processed and recorded, return the cached row instead of re-running. - // Only safe AFTER verify so an unverified replay can't probe for the - // existence of cached rows. - paymentHash = hashPaymentHeader(paymentHeader); - const cached = await findCachedX402Response(paymentHash); - if (cached) { - if (cached.status === "completed") { - return c.json({ - solution: sol.slug, - steps: (cached.output as { steps?: unknown })?.steps ?? cached.output, - _meta: { - solution: sol.slug, - replayed: true, - note: "Returned from cache — same X-Payment header was already processed.", - latency_ms: cached.latencyMs, - payment: cached.settlementId - ? { method: "x402", settlement_id: cached.settlementId, price_usd: sol.x402PriceUsd } - : { method: "x402", price_usd: sol.x402PriceUsd }, - }, - }); - } - return c.json( - { error: "Prior request with this payment header failed.", solution: sol.slug, _meta: { replayed: true } }, - 502, - ); - } - } - - // Extract inputs (after verify, before settle — bad input returns 4xx without charging) - let inputs: Record; - try { - inputs = await extractInputs(c, sol.inputSchema); - } catch { - return c.json({ error: "Invalid request body. Expected JSON." }, 400); - } - - // Execute solution steps via shared orchestration module - const result = await executeSolution(sol.id, inputs); - - if (!result) { - return c.json({ error: "Solution has no steps configured." }, 503); - } - - // Settle only if at least one step produced output. All-steps-failed returns - // a 4xx-shaped response and the caller keeps their USDC authorization. - // `result.steps` is a Record. A step - // counts as successful when its value is an object with neither `error` nor - // `skipped` set. - const anyStepSucceeded = Object.values(result.steps).some((v) => { - if (!v || typeof v !== "object") return false; - const obj = v as Record; - return !("error" in obj) && !("skipped" in obj); - }); - if (!anyStepSucceeded) { - return c.json( - { - error: "Solution failed — no steps produced output. No payment was taken.", - solution: sol.slug, - steps: result.steps, - errors: result.errors, - }, - 502, - ); - } - - let settlementId: string | undefined; - if (verified) { - const settled = await settleX402Payment(verified); - if (!settled.valid) { - return c.json( - { error: "Payment settlement failed", detail: settled.error }, - 402, - ); - } - settlementId = settled.settlementId; - if (settlementId) { - c.header("X-Payment-Response", encodePaymentResponseHeader(settlementId)); - } - } - - // CCO P0 #12: AWAIT the record. Settlement happened on-chain and is - // irreversible; if the INSERT fails, recordX402Transaction writes an - // orphan-settlement row for manual reconciliation. If we fire-and-forget - // here, an INSERT failure has nowhere to land. - // F-AUDIT-01 / CCO #3: dataJurisdiction is no longer passed here; it's - // computed inside recordX402Transaction from Strale's actual region + - // LLM reach derived from transparencyTag. - const solPayerAddress = verified ? extractPayerAddress(verified) : null; - await recordX402Transaction({ - capabilityId: null, - solutionSlug: sol.slug, - slug: sol.slug, - inputs, - output: { steps: result.steps, errors: result.errors }, - latencyMs: result.latency_ms, - priceCents: sol.priceCents, - priceUsd: sol.x402PriceUsd, - transparencyTag: "mixed", - settlementId, - payerAddress: solPayerAddress, - paymentHash, - }); - - return c.json({ - solution: sol.slug, - steps: result.steps, - errors: result.errors.length > 0 ? result.errors : undefined, - _meta: { - solution: sol.slug, - step_count: result.step_count, - latency_ms: result.latency_ms, - payment: settlementId - ? { method: "x402", settlement_id: settlementId, price_usd: sol.x402PriceUsd } - : { method: "x402", price_usd: sol.x402PriceUsd }, +// ─── Solution execution: /x402/solutions/:slug — retired ──────────────────── +// +// Retired DEC-20260503-A 2026-05-04. Phase 1b removal: separate to-do. +// The public solutions surface (including x402 execution) is retired with +// the rest of the solutions concept. lib/solution-executor.ts is kept for +// any future bundled-product module that reuses the composition tech. + +x402GatewayV2.on(["GET", "POST"], "/solutions/:slug", (c) => + c.json( + { + error_code: "gone", + message: + "Solutions surface retired per DEC-20260503-A. Visit https://strale.io for Counterparty Assurance.", + deprecated_at: "2026-05-04", + alternative: "https://strale.io", }, - }); -}); + 410, + ), +); // ─── Wildcard capability handler: /x402/:slug ─────────────────────────────── @@ -1256,24 +1041,14 @@ export async function getX402Manifest(): Promise<{ // /.well-known/x402.json. The canonical numeric value is always // available server-side as `priceCents` (EUR) and the live USD figure // is `eurCentsToUsd(priceCents)` per DEC-20260502-A. - const endpoints = [ - ...[..._capCache.values()].map((cap) => ({ - path: `/x402/${cap.slug}`, - method: cap.x402Method, - price: cap.x402PriceUsd.toFixed(2), - currency: "USDC", - network: NETWORK, - description: cap.description, - })), - ...[..._solCache.values()].map((sol) => ({ - path: `/x402/solutions/${sol.slug}`, - method: "POST", - price: sol.x402PriceUsd.toFixed(2), - currency: "USDC", - network: NETWORK, - description: sol.description, - })), - ]; + const endpoints = [..._capCache.values()].map((cap) => ({ + path: `/x402/${cap.slug}`, + method: cap.x402Method, + price: cap.x402PriceUsd.toFixed(2), + currency: "USDC", + network: NETWORK, + description: cap.description, + })); return { x402: true, @@ -1290,10 +1065,9 @@ export async function getX402Manifest(): Promise<{ // against them fails. They remain reachable via /v1/capabilities and /x402/catalog. export async function getX402WellKnownResources(): Promise<{ version: number; resources: string[] }> { await ensureCache(); - const resources = [ - ...[..._capCache.values()].filter((cap) => cap.x402PriceUsd > 0).map((cap) => `${BASE_URL}/x402/${cap.slug}`), - ...[..._solCache.values()].filter((sol) => sol.x402PriceUsd > 0).map((sol) => `${BASE_URL}/x402/solutions/${sol.slug}`), - ]; + const resources = [..._capCache.values()] + .filter((cap) => cap.x402PriceUsd > 0) + .map((cap) => `${BASE_URL}/x402/${cap.slug}`); return { version: 1, resources }; } @@ -1322,20 +1096,6 @@ export async function getX402OpenApiPaths(): Promise> { }; } - for (const sol of _solCache.values()) { - if (sol.x402PriceUsd <= 0) continue; - paths[`/x402/solutions/${sol.slug}`] = { - post: buildX402Operation({ - summary: `${sol.name} (x402 solution)`, - description: sol.description, - method: "post", - priceUsd: sol.x402PriceUsd, - inputSchema: sol.inputSchema, - outputSchema: sol.outputSchema, - }), - }; - } - return paths; } diff --git a/apps/api/src/web3-assurance/composer.test.ts b/apps/api/src/web3-assurance/composer.test.ts deleted file mode 100644 index f9380d41..00000000 --- a/apps/api/src/web3-assurance/composer.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Composer integration test. - * - * Verifies: - * - Evaluator registration produces non-zero count - * - Target-type filtering (wallet target only triggers wallet evaluators) - * - Composer returns evidence map even when individual evaluators fail - * - Verdict computation produces a valid verdict given the evidence - * - * Network calls are made against real free APIs (DefiLlama, Sourcify, - * ScamSniffer GitHub, EAS GraphQL); test is allowed to fail offline. - */ - -import { describe, it, expect } from "vitest"; -import { compose, computeVerdict, getEvaluators } from "./index.js"; -import { buildExplanationChain } from "./explanation.js"; - -describe("web3-assurance composer", () => { - it("registers evaluators on import", () => { - const evaluators = getEvaluators(); - expect(evaluators.length).toBeGreaterThanOrEqual(15); - const names = evaluators.map((e) => e.name); - expect(names).toContain("wallet-history-risk"); - expect(names).toContain("token-safety"); - expect(names).toContain("contract-verification"); - expect(names).toContain("protocol-risk"); - expect(names).toContain("sourcify-verification"); - expect(names).toContain("mixer-graded"); - expect(names).toContain("scam-cluster"); - expect(names).toContain("eas-attestations"); - expect(names).toContain("erc-8004-reputation"); - expect(names).toContain("sister-rug"); - expect(names).toContain("pre-trade-simulation"); - }); - - it("filters evaluators by target type — wallet target excludes token-only evaluators", () => { - const evaluators = getEvaluators(); - const walletCtx = { - target: "0x0000000000000000000000000000000000000001", - targetType: "wallet" as const, - chain: "ethereum", - mode: "outbound" as const, - }; - const applicable = evaluators.filter((e) => e.appliesTo(walletCtx)); - const names = applicable.map((e) => e.name); - expect(names).toContain("wallet-history-risk"); - expect(names).not.toContain("protocol-risk"); - expect(names).not.toContain("sourcify-verification"); - }); - - it("does not run name-based sanctions screening on hex-address targets", () => { - const evaluators = getEvaluators(); - const sanctions = evaluators.find((e) => e.name === "sanctions"); - expect(sanctions).toBeDefined(); - - const hexTargets = ["wallet", "contract", "token", "bridge"] as const; - for (const targetType of hexTargets) { - const ctx = { - target: "0x0000000000000000000000000000000000000001", - targetType, - chain: "ethereum", - mode: "outbound" as const, - }; - expect(sanctions?.appliesTo(ctx)).toBe(false); - } - - const stringCtx = { - target: "aave", - targetType: "protocol" as const, - chain: "ethereum", - mode: "outbound" as const, - }; - expect(sanctions?.appliesTo(stringCtx)).toBe(true); - }); - - it("returns evidence map + verdict + reason_codes for an unrecognised wallet", async () => { - const composed = await compose({ - target: "0x0000000000000000000000000000000000000001", - target_type: "wallet", - chain: "ethereum", - }); - expect(composed.context.target).toBe("0x0000000000000000000000000000000000000001"); - expect(composed.context.targetType).toBe("wallet"); - expect(composed.context.mode).toBe("outbound"); - expect(composed.evidence).toBeDefined(); - expect(composed.results.length).toBeGreaterThan(0); - - const verdict = computeVerdict(composed); - expect(["proceed", "review", "block", "insufficient_evidence"]).toContain(verdict.verdict); - expect(verdict.confidence).toBeGreaterThanOrEqual(0); - expect(verdict.confidence).toBeLessThanOrEqual(1); - expect(verdict.expires_at).toBeDefined(); - expect(Array.isArray(verdict.reason_codes)).toBe(true); - for (const code of verdict.reason_codes) { - expect(code).toMatch(/^[A-Z][A-Z0-9_]*$/); - } - }, 30000); - - it("flags Tornado Cash addresses as known mixer (graded, not blocked)", async () => { - const composed = await compose({ - target: "0x8589427373d6d84e98730d7795d8f6f8731fda16", - target_type: "wallet", - chain: "ethereum", - }); - const mixer = composed.evidence["mixer-graded"]; - expect(mixer).toBeDefined(); - expect(mixer?.is_known_mixer).toBe(true); - expect(mixer?.category).toBe("delisted"); - expect(typeof mixer?.risk_weight).toBe("number"); - - const verdict = computeVerdict(composed); - expect(verdict.reason_codes).toContain("MIXER_DELISTED_ELEVATED"); - expect(verdict.verdict).toBe("review"); - }, 30000); - - it("flags the KelpDAO 1-of-1 DVN configuration as bridge:single_point_of_failure (BLOCK)", async () => { - const composed = await compose({ - target: "0xa1290d69c65a6fe4df752f95823fae25cb99e5a7", - target_type: "bridge", - chain: "ethereum", - }); - const config = composed.evidence["bridge-config-risk"]; - expect(config).toBeDefined(); - expect(config?.indexed).toBe(true); - expect(config?.is_single_point_of_failure).toBe(true); - expect(config?.risk_level).toBe("critical"); - - const verdict = computeVerdict(composed); - expect(verdict.reason_codes).toContain("BRIDGE_SINGLE_POINT_OF_FAILURE"); - expect(verdict.reason_codes).toContain("BRIDGE_CONFIG_CRITICAL"); - expect(verdict.verdict).toBe("block"); - - const chain = buildExplanationChain(composed, verdict); - const spofLink = chain.find((c) => c.reason_code === "BRIDGE_SINGLE_POINT_OF_FAILURE"); - expect(spofLink).toBeDefined(); - expect(spofLink?.severity).toBe("critical"); - expect(spofLink?.source_evaluator).toBe("bridge-config-risk"); - expect(spofLink?.evidence_excerpt).toHaveProperty("dvn_config"); - expect(spofLink?.why).toMatch(/KelpDAO|single-point-of-failure/i); - }, 30000); - - it("surfaces cross-protocol exposure for a known protocol with oracle dependencies", async () => { - const composed = await compose({ - target: "aave", - target_type: "protocol", - chain: "ethereum", - }); - const exposure = composed.evidence["cross-protocol-exposure"]; - expect(exposure).toBeDefined(); - if (exposure?.found === true) { - expect(Array.isArray(exposure?.oracle_dependencies)).toBe(true); - expect(["critical", "high", "medium", "low", "unknown"]).toContain(exposure?.exposure_risk_level); - } - }, 30000); - - it("reverse-call mode runs only critical evaluators and skips opportunistic ones", async () => { - const composed = await compose({ - target: "0x0000000000000000000000000000000000000001", - target_type: "wallet", - chain: "ethereum", - mode: "reverse-call", - }); - expect(composed.context.mode).toBe("reverse-call"); - const opportunisticSkipped = composed.results.filter( - (r) => r.skipped_reason === "opportunistic_skipped_in_reverse_call_mode", - ); - expect(opportunisticSkipped.length).toBeGreaterThan(0); - const ranEvaluators = composed.results.filter((r) => !r.skipped_reason); - const ranNames = ranEvaluators.map((r) => r.evaluator); - expect(ranNames).toContain("mixer-graded"); - expect(ranNames).toContain("scam-cluster"); - expect(ranNames).not.toContain("wallet-identity"); - expect(ranNames).not.toContain("eas-attestations"); - }, 30000); -}); diff --git a/apps/api/src/web3-assurance/composer.ts b/apps/api/src/web3-assurance/composer.ts deleted file mode 100644 index 03a97a0d..00000000 --- a/apps/api/src/web3-assurance/composer.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Web3 Assurance — composer. - * - * Orchestrates evaluators in parallel with per-evaluator timeout. Two modes: - * outbound — agent vetting recipient pre-payment. 8s budget, all - * evaluators run. - * reverse-call — service publisher gating an inbound x402 buyer in - * real-time. 600ms per-evaluator cap, only critical - * evaluators run. - * - * Verdict computation lives in verdict.ts. Audit-trail wrapping happens in - * the route handler so the composer stays pure. - */ - -import { getEvaluators } from "./evaluators/index.js"; -import { recordSourceCall } from "./source-quality.js"; -import type { - EvaluatorContext, - EvaluatorResult, - TargetType, - Action, - Mode, - Web3AssuranceRequest, -} from "./types.js"; - -const TIMEOUT_OUTBOUND_MS = 8000; -const TIMEOUT_REVERSE_CALL_MS = 600; - -const HEX_ADDRESS = /^0x[a-fA-F0-9]{40}$/; -const SOLANA_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; - -export function inferTargetType(target: string, hint?: TargetType): TargetType { - if (hint) return hint; - if (target.startsWith("http://") || target.startsWith("https://") || target.includes(".")) { - if (!HEX_ADDRESS.test(target) && !SOLANA_ADDRESS.test(target)) return "domain"; - } - return "wallet"; -} - -export function inferChain(target: string, hint?: string): string { - if (hint) return hint; - if (HEX_ADDRESS.test(target)) return "ethereum"; - if (SOLANA_ADDRESS.test(target)) return "solana"; - return "ethereum"; -} - -function buildContext(req: Web3AssuranceRequest): EvaluatorContext { - const targetType = inferTargetType(req.target, req.target_type); - const chain = inferChain(req.target, req.chain); - const mode: Mode = req.mode ?? "outbound"; - return { - target: req.target, - targetType, - chain, - action: req.action as Action | undefined, - amountUsd: req.amount_usd, - agentId: req.agent_id, - callerJurisdiction: req.caller_jurisdiction, - mode, - }; -} - -async function runEvaluator( - ctx: EvaluatorContext, - evaluator: ReturnType[number], - timeoutMs: number, -): Promise { - const start = Date.now(); - try { - const result = await Promise.race([ - evaluator.run(ctx), - new Promise((_, reject) => - setTimeout(() => reject(new Error("evaluator-timeout")), timeoutMs), - ), - ]); - return { - evaluator: evaluator.name, - ms: Date.now() - start, - cached: false, - ...result, - }; - } catch (err) { - return { - evaluator: evaluator.name, - ok: false, - evidence: null, - provenance: { - source: "internal", - fetched_at: new Date().toISOString(), - }, - ms: Date.now() - start, - error: err instanceof Error ? err.message : String(err), - }; - } -} - -export interface ComposeResult { - context: EvaluatorContext; - results: EvaluatorResult[]; - evidence: Record | null>; -} - -export async function compose(req: Web3AssuranceRequest): Promise { - const ctx = buildContext(req); - const allEvaluators = getEvaluators().filter((e) => e.appliesTo(ctx)); - - const applicable = ctx.mode === "reverse-call" - ? allEvaluators.filter((e) => e.priority === "critical") - : allEvaluators; - - const skippedOpportunistic = ctx.mode === "reverse-call" - ? allEvaluators.filter((e) => e.priority === "opportunistic") - : []; - - const timeoutMs = ctx.mode === "reverse-call" ? TIMEOUT_REVERSE_CALL_MS : TIMEOUT_OUTBOUND_MS; - - const results = await Promise.all(applicable.map((e) => runEvaluator(ctx, e, timeoutMs))); - - for (const r of results) { - recordSourceCall(r.provenance.source, r.ms, r.ok); - } - - for (const e of skippedOpportunistic) { - results.push({ - evaluator: e.name, - ok: false, - evidence: null, - provenance: { - source: "internal", - fetched_at: new Date().toISOString(), - }, - ms: 0, - skipped_reason: "opportunistic_skipped_in_reverse_call_mode", - }); - } - - const evidence: Record | null> = {}; - for (const r of results) { - evidence[r.evaluator] = r.ok ? r.evidence : null; - } - - return { context: ctx, results, evidence }; -} diff --git a/apps/api/src/web3-assurance/data/known-rug-bytecodes.ts b/apps/api/src/web3-assurance/data/known-rug-bytecodes.ts deleted file mode 100644 index 5a8d2382..00000000 --- a/apps/api/src/web3-assurance/data/known-rug-bytecodes.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Curated registry of known-rug contract bytecodes. - * - * Each entry maps a normalized SHA-256 hash of deployed bytecode to its - * provenance. v0.1 seeds with a small set of known patterns; v0.2 expands - * via continuous indexing of new rugs from REKT Database + DefiLlama Hacks - * + ScamSniffer reports. - * - * Normalization strategy: - * - Strip the trailing CBOR metadata block (Solidity 0.8.x appends a - * ~53-byte CBOR-encoded metadata hash; same source compiled twice - * produces different metadata so we exclude it) - * - Lowercase - * - SHA-256 the resulting bytes - * - * Per DEC-20260428-A, all entries verified manually from public on-chain - * reads; no scraping. - * - * v0.1 seed is intentionally small. The infrastructure (hashing, - * normalization, lookup) is what compounds — adding more entries is a - * data-curation task, not an engineering one. As a Strale-internal - * artifact this is data-as-moat: every new rug we hash and add raises - * the cost of competitors replicating the index. - * - * Seeding criteria (to keep the index high-precision): - * - The bytecode pattern, when matched in a NEW deployment, must - * correlate with rug/scam intent (honeypot transfer hooks, - * drainer factories, sell-blocking ERC-20 templates). - * - Exploit-target contracts (e.g. Nomad Replica, Euler eToken, - * Curve pools) are EXPLICITLY NOT in scope: their bytecode - * re-appearing in a fork is normal, not a rug signal. - * - Each entry needs a public postmortem URL we can republish under - * `provenance.primary_source_reference` per DEC-20260428-A. - * - * 2026-05-01 curation pass: rejected 7 exploit-target candidates - * (PAID, Nomad, Euler eUSDC/eDAI, Curve CRV/ETH, Harvest fUSDC, - * dForce Lendf.Me) on the false-positive-risk argument above. Right - * sources for the next pass are honeypot/drainer catalogs (GoPlus - * Token Security API, Tokensniffer, Forta scam-detector bot) — these - * publish pattern bytecode where match = malicious by definition. - */ - -export interface KnownRugBytecodeEntry { - bytecode_sha256: string; - pattern_name: string; - first_seen_address: string; - first_seen_chain: string; - first_seen_at: string; - classification: "rug_pull" | "honeypot" | "drainer_factory" | "scam_token"; - amount_lost_usd_estimate: number | null; - notes: string; -} - -export const KNOWN_RUG_BYTECODES: readonly KnownRugBytecodeEntry[] = []; - -const HASH_INDEX: Map = new Map(); -for (const entry of KNOWN_RUG_BYTECODES) { - HASH_INDEX.set(entry.bytecode_sha256.toLowerCase(), entry); -} - -export function lookupRugBytecode(hash: string): KnownRugBytecodeEntry | null { - return HASH_INDEX.get(hash.toLowerCase()) ?? null; -} - -export function getRugBytecodeCount(): number { - return KNOWN_RUG_BYTECODES.length; -} diff --git a/apps/api/src/web3-assurance/data/layerzero-oapps.ts b/apps/api/src/web3-assurance/data/layerzero-oapps.ts deleted file mode 100644 index 36341078..00000000 --- a/apps/api/src/web3-assurance/data/layerzero-oapps.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Curated LayerZero OApp DVN-configuration registry. - * - * v0.1 of the bridge-config-risk evaluator ships with a manual seed of - * known LayerZero OApps and their verification configurations. v0.2 will - * replace this with live on-chain `endpoint.getConfig()` reads, but the - * seed catches the KelpDAO use case (1-of-1 DVN single-point-of-failure) - * and similar high-value OApps immediately. - * - * Seed sourcing methodology: - * - LayerZero Scan public OApp registry - * - Public postmortems for incidents (KelpDAO Apr 2026, Radiant Oct 2024) - * - LayerZero V2 endpoint `getConfig` reads (manually performed for seed) - * - * Per DEC-20260428-A, Strale itself does not scrape; this seed is curated - * from public on-chain reads + public incident reports. - * - * "Reputable DVN" classification follows community consensus (LayerZero - * Labs, Google Cloud, Polyhedra, Nethermind, Stargate, P2P, Animoca, - * Restake, BCW, etc. are reputable; unknown signer addresses are not). - */ - -export interface LayerZeroOAppEntry { - /** OApp contract address (the cross-chain application contract) */ - address: string; - /** Chain on which this OApp is deployed */ - chain: string; - /** Human-readable protocol name */ - protocol_name: string; - /** OApp category for downstream classification */ - category: "stablecoin_oft" | "lst_oft" | "lrt_oft" | "bridge" | "swap" | "messaging" | "other"; - dvn_config: { - required_dvn_count: number; - optional_dvn_count: number; - optional_dvn_threshold: number; - required_dvns: string[]; - optional_dvns: string[]; - }; - reputable_dvn_count: number; - is_single_point_of_failure: boolean; - spof_modes: string[]; - historical_incidents: Array<{ - date: string; - classification: string; - amount_usd: number; - notes: string; - }>; - notes: string; - config_last_verified_at: string; -} - -const REPUTABLE_DVNS = new Set([ - "LayerZero Labs", - "Google Cloud", - "Polyhedra", - "Nethermind", - "Stargate", - "P2P", - "Animoca", - "Restake", - "BCW", - "Switchboard", - "Horizen Labs", -]); - -export const LAYERZERO_OAPPS: readonly LayerZeroOAppEntry[] = [ - { - address: "0xa1290d69c65a6fe4df752f95823fae25cb99e5a7", - chain: "ethereum", - protocol_name: "KelpDAO rsETH OFT (pre-incident config)", - category: "lrt_oft", - dvn_config: { - required_dvn_count: 1, - optional_dvn_count: 0, - optional_dvn_threshold: 0, - required_dvns: ["LayerZero Labs"], - optional_dvns: [], - }, - reputable_dvn_count: 1, - is_single_point_of_failure: true, - spof_modes: [ - "single_required_dvn", - "no_optional_dvns", - "no_threshold_redundancy", - ], - historical_incidents: [ - { - date: "2026-04-18", - classification: "bridge_dvn_compromise", - amount_usd: 292000000, - notes: - "Attacker compromised internal RPC nodes and DDoS'd external nodes to feed false data to the single-point-of-failure DVN. Attribution: Lazarus Group (DPRK). $177M cascading bad debt at Aave. Vulnerability was the 1-of-1 DVN configuration, NOT a code bug. Strale's bridge-config-risk evaluator would have flagged this configuration as critical-SPOF before any transaction.", - }, - ], - notes: - "Headline KelpDAO failure mode. Configuration was reduced from a more redundant setup to 1-of-1 in the months prior to the exploit; recommended-DVN best-practice violated.", - config_last_verified_at: "2026-04-18", - }, - { - address: "0x77b2043768d28e9c9ab44e1abfc95944bce57931", - chain: "ethereum", - protocol_name: "Stargate Finance V2 USDC OFT", - category: "stablecoin_oft", - dvn_config: { - required_dvn_count: 2, - optional_dvn_count: 1, - optional_dvn_threshold: 0, - required_dvns: ["Stargate", "LayerZero Labs"], - optional_dvns: ["Polyhedra"], - }, - reputable_dvn_count: 3, - is_single_point_of_failure: false, - spof_modes: [], - historical_incidents: [], - notes: - "Standard Stargate V2 OFT configuration. 2-of-2 required + 1 optional, all reputable DVNs. Production-grade redundancy.", - config_last_verified_at: "2026-04-30", - }, - { - address: "0x77b2043768d28e9c9ab44e1abfc95944bce57931", - chain: "base", - protocol_name: "Stargate Finance V2 USDC OFT (Base)", - category: "stablecoin_oft", - dvn_config: { - required_dvn_count: 2, - optional_dvn_count: 1, - optional_dvn_threshold: 0, - required_dvns: ["Stargate", "LayerZero Labs"], - optional_dvns: ["Polyhedra"], - }, - reputable_dvn_count: 3, - is_single_point_of_failure: false, - spof_modes: [], - historical_incidents: [], - notes: "Same config as Ethereum mainnet deployment.", - config_last_verified_at: "2026-04-30", - }, - { - address: "0x152d109ca56432aaaaee1f2bd4d77a9ab78f9d56", - chain: "ethereum", - protocol_name: "Radiant Capital OFTs (pre-incident config)", - category: "bridge", - dvn_config: { - required_dvn_count: 1, - optional_dvn_count: 0, - optional_dvn_threshold: 0, - required_dvns: ["LayerZero Labs"], - optional_dvns: [], - }, - reputable_dvn_count: 1, - is_single_point_of_failure: true, - spof_modes: ["single_required_dvn", "no_optional_dvns"], - historical_incidents: [ - { - date: "2024-10-16", - classification: "multi-sig_compromise", - amount_usd: 50000000, - notes: - "Radiant DAO multi-sig was compromised, allowing the attacker to take over OFT contracts. Bridge-config alone wouldn't have caught the multi-sig compromise, but the absent DVN redundancy made the cross-chain damage worse.", - }, - ], - notes: - "Historical example of a cross-chain protocol with weak DVN configuration; included in the seed as a negative reference point.", - config_last_verified_at: "2024-10-15", - }, -]; - -const ADDRESS_INDEX: Map = new Map(); -for (const entry of LAYERZERO_OAPPS) { - ADDRESS_INDEX.set(`${entry.chain}:${entry.address.toLowerCase()}`, entry); -} - -export function lookupLayerZeroOApp( - address: string, - chain: string, -): LayerZeroOAppEntry | null { - return ADDRESS_INDEX.get(`${chain.toLowerCase()}:${address.toLowerCase()}`) ?? null; -} - -export function isReputableDvn(dvn: string): boolean { - return REPUTABLE_DVNS.has(dvn); -} diff --git a/apps/api/src/web3-assurance/data/mixer-addresses.ts b/apps/api/src/web3-assurance/data/mixer-addresses.ts deleted file mode 100644 index 420d5212..00000000 --- a/apps/api/src/web3-assurance/data/mixer-addresses.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Curated mixer / privacy-pool address lists. - * - * Per the March 2025 OFAC Tornado Cash delist and the Treasury's March 2026 - * acknowledgement of legitimate mixer use, this is NOT a binary blocklist. - * Each entry has a `regulatory_status` and a `risk_weight` so the evaluator - * can produce graded output rather than a hard ban. - * - * Categories: - * - sanctioned: actively listed by OFAC/UN/EU at the address level - * - delisted: previously sanctioned, now removed (Tornado Cash post-2025) - * - high_risk: not currently sanctioned but documented criminal misuse - * (Sinbad, Wasabi CoinJoin, etc.) - * - privacy: privacy-preserving service with no documented criminal nexus - * - * Lowercased addresses for case-insensitive lookup. Data file rather than - * runtime fetch so v1 has no upstream dependency for mixer detection. - * - * Maintenance: refreshed manually at first; OFAC updates are infrequent - * enough that quarterly review is acceptable for v1. v2 should ingest - * OFAC's published crypto-specific list automatically. - */ - -export type MixerCategory = "sanctioned" | "delisted" | "high_risk" | "privacy"; - -export interface MixerEntry { - address: string; - chain: string; - category: MixerCategory; - service: string; - risk_weight: number; - notes: string; -} - -export const MIXER_ADDRESSES: readonly MixerEntry[] = [ - { - address: "0x8589427373d6d84e98730d7795d8f6f8731fda16", - chain: "ethereum", - category: "delisted", - service: "Tornado Cash (router)", - risk_weight: 0.5, - notes: "OFAC sanctioned 2022-08; delisted 2025-03 after Fifth Circuit ruling. Treasury 2026 report acknowledges legitimate use.", - }, - { - address: "0x722122df12d4e14e13ac3b6895a86e84145b6967", - chain: "ethereum", - category: "delisted", - service: "Tornado Cash (proxy)", - risk_weight: 0.5, - notes: "Tornado Cash proxy address. Same regulatory history.", - }, - { - address: "0xdd4c48c0b24039969fc16d1cdf626eab821d3384", - chain: "ethereum", - category: "delisted", - service: "Tornado Cash (0.1 ETH pool)", - risk_weight: 0.4, - notes: "Tornado Cash pool contract.", - }, - { - address: "0x910cbd523d972eb0a6f4cae4618ad62622b39dbf", - chain: "ethereum", - category: "delisted", - service: "Tornado Cash (1 ETH pool)", - risk_weight: 0.4, - notes: "Tornado Cash pool contract.", - }, - { - address: "0xa160cdab225685da1d56aa342ad8841c3b53f291", - chain: "ethereum", - category: "delisted", - service: "Tornado Cash (100 ETH pool)", - risk_weight: 0.5, - notes: "Tornado Cash pool contract; larger pool sizes carry higher inherent risk.", - }, - { - address: "0x47ce0c6ed5b0ce3d3a51fdb1c52dc66a7c3c2936", - chain: "ethereum", - category: "delisted", - service: "Tornado Cash (10 ETH pool)", - risk_weight: 0.45, - notes: "Tornado Cash pool contract.", - }, - { - address: "0xb541fc07bc7619fd4062a54d96268525cbc6ffef", - chain: "ethereum", - category: "high_risk", - service: "Sinbad (Bitcoin-derived ETH wrapper)", - risk_weight: 0.85, - notes: "Sinbad mixer; OFAC sanctioned 2023-11. Documented Lazarus Group use.", - }, -]; - -const ADDRESS_INDEX: Map = new Map(); -for (const entry of MIXER_ADDRESSES) { - ADDRESS_INDEX.set(entry.address.toLowerCase(), entry); -} - -export function lookupMixerAddress(address: string): MixerEntry | null { - return ADDRESS_INDEX.get(address.toLowerCase()) ?? null; -} diff --git a/apps/api/src/web3-assurance/data/stablecoin-issuers.ts b/apps/api/src/web3-assurance/data/stablecoin-issuers.ts deleted file mode 100644 index d59fd9bc..00000000 --- a/apps/api/src/web3-assurance/data/stablecoin-issuers.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Stablecoin issuer registry for the stablecoin-issuer evaluator. - * - * Maps known stablecoin contract addresses to their issuer + regulatory - * jurisdiction + freeze-capability + redemption model. Direct value to - * EU-CASP buyers ahead of MiCA Q3 2026 enforcement: agents receiving - * USDC vs USDT vs DAI vs PYUSD have materially different freeze risk, - * deposit-insurance status, and regulatory exposure. - * - * Data sourced from public regulator filings + issuer disclosures. Not - * scraping; all entries verified manually against issuer documentation. - */ - -export type StablecoinJurisdiction = - | "US_NY_BitLicense" - | "US_OCC" - | "EU_MiCA_EMI" - | "BVI" - | "Bermuda" - | "decentralized" - | "unknown"; - -export type FreezeCapability = "freezable" | "non_freezable" | "limited_freeze"; - -export interface StablecoinIssuerEntry { - contract_address: string; - chain: string; - symbol: string; - issuer: string; - jurisdiction: StablecoinJurisdiction; - freeze_capability: FreezeCapability; - reserve_disclosure: "monthly_attestation" | "quarterly_attestation" | "audited_annual" | "on_chain_only" | "opaque"; - mica_compliant: boolean; - notes: string; -} - -export const STABLECOIN_ISSUERS: readonly StablecoinIssuerEntry[] = [ - { - contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - chain: "ethereum", - symbol: "USDC", - issuer: "Circle", - jurisdiction: "EU_MiCA_EMI", - freeze_capability: "freezable", - reserve_disclosure: "monthly_attestation", - mica_compliant: true, - notes: - "Circle holds an EMI license in France (ACPR-authorised) and is MiCA-compliant in the EU as of 2024-07. Reserves are monthly-attested by Deloitte.", - }, - { - contract_address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", - chain: "base", - symbol: "USDC", - issuer: "Circle", - jurisdiction: "EU_MiCA_EMI", - freeze_capability: "freezable", - reserve_disclosure: "monthly_attestation", - mica_compliant: true, - notes: "Same issuer as Ethereum mainnet USDC.", - }, - { - contract_address: "0xdac17f958d2ee523a2206206994597c13d831ec7", - chain: "ethereum", - symbol: "USDT", - issuer: "Tether", - jurisdiction: "BVI", - freeze_capability: "freezable", - reserve_disclosure: "quarterly_attestation", - mica_compliant: false, - notes: - "Tether is BVI-domiciled and has not obtained MiCA authorisation. EU CASPs are de-listing USDT post-MiCA enforcement; receiving USDT in EU jurisdictions raises regulatory risk for the recipient.", - }, - { - contract_address: "0x6b175474e89094c44da98b954eedeac495271d0f", - chain: "ethereum", - symbol: "DAI", - issuer: "MakerDAO", - jurisdiction: "decentralized", - freeze_capability: "non_freezable", - reserve_disclosure: "on_chain_only", - mica_compliant: false, - notes: - "DAI is governed by MakerDAO with on-chain collateral. No issuer entity to freeze; reserves are observable on-chain. Not MiCA-authorised because there is no centralised issuer to authorise.", - }, - { - contract_address: "0x6c3ea9036406852006290770bedfcaba0e23a0e8", - chain: "ethereum", - symbol: "PYUSD", - issuer: "Paxos (PayPal partnership)", - jurisdiction: "US_NY_BitLicense", - freeze_capability: "freezable", - reserve_disclosure: "monthly_attestation", - mica_compliant: false, - notes: - "Paxos issues under NYDFS BitLicense. Not MiCA-authorised; EU receipt may require additional compliance review.", - }, - { - contract_address: "0x4c9edd5852cd905f086c759e8383e09bff1e68b3", - chain: "ethereum", - symbol: "USDe", - issuer: "Ethena Labs", - jurisdiction: "BVI", - freeze_capability: "limited_freeze", - reserve_disclosure: "monthly_attestation", - mica_compliant: false, - notes: - "Synthetic-dollar backed by perp-funding-rate strategies, not classical fiat reserves. Different risk profile from regulated stablecoins.", - }, - { - contract_address: "0xc5f0f7b66764f6ec8c8dff7ba683102295e16409", - chain: "ethereum", - symbol: "FDUSD", - issuer: "First Digital Trust", - jurisdiction: "Bermuda", - freeze_capability: "freezable", - reserve_disclosure: "monthly_attestation", - mica_compliant: false, - notes: "First Digital is HK-based with Bermuda trust structure. Not MiCA-authorised.", - }, - { - contract_address: "0x8e870d67f660d95d5be530380d0ec0bd388289e1", - chain: "ethereum", - symbol: "USDP", - issuer: "Paxos", - jurisdiction: "US_NY_BitLicense", - freeze_capability: "freezable", - reserve_disclosure: "monthly_attestation", - mica_compliant: false, - notes: "Paxos USD; same issuer regime as PYUSD.", - }, -]; - -const ADDRESS_INDEX: Map = new Map(); -for (const entry of STABLECOIN_ISSUERS) { - ADDRESS_INDEX.set(`${entry.chain}:${entry.contract_address.toLowerCase()}`, entry); -} - -export function lookupStablecoinIssuer( - contractAddress: string, - chain: string, -): StablecoinIssuerEntry | null { - return ( - ADDRESS_INDEX.get(`${chain.toLowerCase()}:${contractAddress.toLowerCase()}`) ?? null - ); -} diff --git a/apps/api/src/web3-assurance/disagreement.ts b/apps/api/src/web3-assurance/disagreement.ts deleted file mode 100644 index 207c7f82..00000000 --- a/apps/api/src/web3-assurance/disagreement.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Web3 Assurance — cross-vendor disagreement detector. - * - * One of Strale's Tier-1 differentiators per the strategic memo: when our - * upstream sources disagree, we surface it as an explicit response field. - * Single-source competitors can't do this because they don't run multiple - * sources. The disagreement field becomes a category-defining output. - * - * v0.1 detects four explicit disagreement classes: - * 1. token_safety_vs_protocol_risk — GoPlus says clean but DefiLlama - * hacks DB shows recent incident - * 2. contract_verification_mismatch — Sourcify says verified but - * Etherscan says unverified (or vice - * versa) - * 3. wallet_risk_vs_mixer — wallet-history-risk says low_risk - * but mixer-graded says known mixer - * 4. wallet_risk_vs_scam_cluster — wallet-history-risk says low but - * scam-cluster matches - * - * The list is conservative — false positives (sources that look like they - * disagree but actually answer different questions) erode the value. Each - * detector is hand-coded against actual evidence-shape from the evaluators. - */ - -import type { ComposeResult } from "./composer.js"; - -export type DisagreementClass = - | "token_safety_vs_protocol_risk" - | "contract_verification_mismatch" - | "wallet_risk_vs_mixer" - | "wallet_risk_vs_scam_cluster"; - -export interface DisagreementEntry { - class: DisagreementClass; - sources: string[]; - description: string; - resolution_hint: string; -} - -function getStr(v: unknown): string | null { - return typeof v === "string" ? v : null; -} - -function getBool(v: unknown): boolean { - return v === true || v === "true" || v === 1 || v === "1"; -} - -export function detectDisagreements(compose: ComposeResult): DisagreementEntry[] { - const out: DisagreementEntry[] = []; - const evidence = compose.evidence; - - const tokenSafety = evidence["token-safety"]; - const protocolRisk = evidence["protocol-risk"]; - if ( - tokenSafety && - protocolRisk && - protocolRisk.found === true && - (tokenSafety.risk_level === "low" || tokenSafety.risk_level === "medium") && - typeof protocolRisk.incidents === "object" && - protocolRisk.incidents !== null - ) { - const incidents = protocolRisk.incidents as Record; - const days = - typeof incidents.days_since_last_incident === "number" - ? incidents.days_since_last_incident - : null; - if (days !== null && days < 90) { - out.push({ - class: "token_safety_vs_protocol_risk", - sources: ["api.gopluslabs.io", "api.llama.fi"], - description: `Token-safety reports risk_level="${tokenSafety.risk_level}" but DefiLlama hacks DB shows the parent protocol was exploited ${days} days ago.`, - resolution_hint: - "Treat as elevated. Token-level safety does not capture protocol-level incidents in the dependency chain.", - }); - } - } - - const sourcify = evidence["sourcify-verification"]; - const contractVerify = evidence["contract-verification"]; - if (sourcify && contractVerify) { - const sourcifyVerified = sourcify.verified === true; - const etherscanVerified = contractVerify.is_verified === true; - if (sourcifyVerified !== etherscanVerified) { - out.push({ - class: "contract_verification_mismatch", - sources: ["sourcify.dev", "etherscan.io"], - description: `Sourcify says verified=${sourcifyVerified}; Etherscan says verified=${etherscanVerified}. The two sources disagree on whether the deployed bytecode matches a known compile.`, - resolution_hint: - "Investigate. Sourcify verifies via embedded metadata hash; Etherscan via source recompile. Mismatch may indicate proxy upgrade, partial verification, or deployment skew.", - }); - } - } - - const walletHistory = evidence["wallet-history-risk"]; - const mixer = evidence["mixer-graded"]; - if (walletHistory && mixer && getBool(mixer.is_known_mixer)) { - const isLow = - getStr(walletHistory.risk_level) === "low" && !getBool(walletHistory.is_malicious); - if (isLow) { - const service = getStr(mixer.service) ?? "unknown mixer"; - const category = getStr(mixer.category) ?? "unclassified"; - out.push({ - class: "wallet_risk_vs_mixer", - sources: ["api.gopluslabs.io", "strale-curated-mixer-list"], - description: `Wallet-history-risk reports risk_level="low" / is_malicious=false but the address is a known mixer (service="${service}", category="${category}").`, - resolution_hint: - "Apply the mixer-specific verdict regardless of the wallet-history score. GoPlus does not flag mixers as malicious by default; that is a category-of-evidence omission, not a contradiction of the underlying transaction record.", - }); - } - } - - const scamCluster = evidence["scam-cluster"]; - if ( - walletHistory && - scamCluster && - getBool(scamCluster.is_scam_cluster) && - getStr(walletHistory.risk_level) === "low" - ) { - out.push({ - class: "wallet_risk_vs_scam_cluster", - sources: ["api.gopluslabs.io", "github.com/scamsniffer/scam-database"], - description: - "Wallet-history-risk reports risk_level=\"low\" but ScamSniffer scam-database has the address on its phishing-cluster list.", - resolution_hint: - "Apply the scam-cluster verdict. Phishing wallets often have low transaction-volume / low-malicious-signal patterns until they sweep, which GoPlus's algorithmic scoring may not catch.", - }); - } - - return out; -} diff --git a/apps/api/src/web3-assurance/evaluators/audit-firms.ts b/apps/api/src/web3-assurance/evaluators/audit-firms.ts deleted file mode 100644 index d4fbb841..00000000 --- a/apps/api/src/web3-assurance/evaluators/audit-firms.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Web3 Assurance — audit-firm aggregation. - * - * Cross-references a contract address against publicly-available audit - * databases from major firms: Certik, Cyfrin (Solodit), OpenZeppelin, - * Sherlock, Code4rena, Hashlock, ConsenSys Diligence. - * - * v1 ships with a curated seed (well-known protocol contracts → audit - * firms that audited them) — small but high-leverage. Live aggregator - * deferred to v1.1 because (a) most firms don't expose machine-readable - * lists, (b) contract↔audit mapping is itself a multi-source problem, - * and (c) the seed-based approach catches the common cases (Aave, Uniswap, - * Compound, Curve, etc.) without scraping. - * - * Per DEC-20260428-A, Strale itself never scrapes. Live aggregator path - * (when shipped) will consume Solodit / Cyfrin's open feed under their - * documented redistribution rights. - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator } from "../types.js"; - -interface AuditEntry { - address: string; - chain: string; - protocol: string; - audits: Array<{ - firm: string; - date?: string; - severity_findings_max?: "critical" | "high" | "medium" | "low" | "informational"; - report_url?: string; - }>; -} - -const SEED_AUDITS: readonly AuditEntry[] = [ - { - address: "0x7d2768de32b0b80b7a3454c06bdac94a69ddc7a9", - chain: "ethereum", - protocol: "Aave V2 LendingPool", - audits: [ - { firm: "OpenZeppelin", date: "2020-08-01", severity_findings_max: "low" }, - { firm: "Certik", date: "2020-09-01", severity_findings_max: "informational" }, - { firm: "Trail of Bits", date: "2020-09-01", severity_findings_max: "low" }, - ], - }, - { - address: "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2", - chain: "ethereum", - protocol: "Aave V3 Pool", - audits: [ - { firm: "OpenZeppelin", date: "2022-01-01", severity_findings_max: "low" }, - { firm: "ABDK", date: "2022-01-01", severity_findings_max: "low" }, - ], - }, - { - address: "0xe592427a0aece92de3edee1f18e0157c05861564", - chain: "ethereum", - protocol: "Uniswap V3 SwapRouter", - audits: [ - { firm: "Trail of Bits", date: "2021-03-01", severity_findings_max: "low" }, - { firm: "ABDK", date: "2021-03-01", severity_findings_max: "informational" }, - ], - }, - { - address: "0xc36442b4a4522e871399cd717abdd847ab11fe88", - chain: "ethereum", - protocol: "Uniswap V3 NonfungiblePositionManager", - audits: [ - { firm: "Trail of Bits", date: "2021-03-01", severity_findings_max: "low" }, - { firm: "ABDK", date: "2021-03-01", severity_findings_max: "informational" }, - ], - }, - { - address: "0xc3d688b66703497daa19211eedff47f25384cdc3", - chain: "ethereum", - protocol: "Compound V3 USDC", - audits: [ - { firm: "OpenZeppelin", date: "2022-08-01", severity_findings_max: "low" }, - { firm: "ChainSecurity", date: "2022-08-01", severity_findings_max: "low" }, - ], - }, - { - address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - chain: "ethereum", - protocol: "Centre USDC FiatToken", - audits: [ - { firm: "Trail of Bits", date: "2018-09-01", severity_findings_max: "low" }, - ], - }, - { - address: "0xdac17f958d2ee523a2206206994597c13d831ec7", - chain: "ethereum", - protocol: "Tether USDT", - audits: [], - }, -]; - -const SEED_INDEX = new Map(); -for (const entry of SEED_AUDITS) { - SEED_INDEX.set(entry.address.toLowerCase(), entry); -} - -const evaluator: Evaluator = { - name: "audit-firms", - priority: "opportunistic", - appliesTo: (ctx) => - (ctx.targetType === "contract" || ctx.targetType === "token" || ctx.targetType === "protocol") && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target), - cacheTTLSeconds: 86400, - cacheKey: (ctx) => `audit-firms:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const entry = SEED_INDEX.get(ctx.target.toLowerCase()); - - if (!entry) { - return { - ok: true, - evidence: { - target: ctx.target, - found: false, - source: "strale-curated-audit-seed", - note: "No audit record in Strale's curated seed. v1.1 will integrate live audit-firm feeds (Solodit, Cyfrin). Treat absence as 'not-yet-indexed', not 'unaudited'.", - }, - provenance: { source: "strale-curated-audit-seed", fetched_at: now }, - }; - } - - return { - ok: true, - evidence: { - target: ctx.target, - found: true, - protocol: entry.protocol, - chain: entry.chain, - audit_count: entry.audits.length, - audits: entry.audits, - firms: entry.audits.map((a) => a.firm), - }, - provenance: { source: "strale-curated-audit-seed", fetched_at: now }, - }; - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/bridge-config-risk.ts b/apps/api/src/web3-assurance/evaluators/bridge-config-risk.ts deleted file mode 100644 index 544e7a64..00000000 --- a/apps/api/src/web3-assurance/evaluators/bridge-config-risk.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Web3 Assurance — bridge configuration risk evaluator. - * - * The KelpDAO failure mode (1-of-1 DVN single-point-of-failure on LayerZero, - * $292M April 18 2026) was *configuration risk*, not code risk. No - * counterparty assurance product on the market analyzes bridge verification - * configuration. This evaluator surfaces: - * - * - DVN count + threshold (LayerZero-specific; covers KelpDAO mode) - * - Reputable-DVN count vs total - * - Single-point-of-failure modes - * - Historical incidents tied to this config - * - * v0.1 ships with a curated seed of known LayerZero OApps (see - * data/layerzero-oapps.ts). v0.2 will replace the seed lookup with live - * on-chain reads via LayerZero V2 endpoint.getConfig(). - * - * For non-LayerZero bridges (Wormhole, Axelar, multi-sig, federated), - * this evaluator returns "not_indexed" — v1.x will add per-protocol - * classifiers as the seed grows. - */ - -import { registerEvaluator } from "./index.js"; -import { lookupLayerZeroOApp, isReputableDvn } from "../data/layerzero-oapps.js"; -import { fetchLiveLayerZeroConfig } from "../lib/layerzero-config.js"; -import type { Evaluator } from "../types.js"; - -const evaluator: Evaluator = { - name: "bridge-config-risk", - priority: "critical", - appliesTo: (ctx) => - (ctx.targetType === "bridge" || ctx.targetType === "contract") && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target), - cacheTTLSeconds: 86400, - cacheKey: (ctx) => `bridge-config:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const entry = lookupLayerZeroOApp(ctx.target, ctx.chain); - - if (!entry) { - const live = await fetchLiveLayerZeroConfig(ctx.target, ctx.chain); - if (live.ok && live.config) { - const cfg = live.config; - const totalDvns = - cfg.requiredDVNs.length + cfg.optionalDVNs.length; - const isSpof = - cfg.requiredDVNCount === 1 && cfg.optionalDVNCount === 0 && - cfg.optionalDVNThreshold === 0; - let level: "critical" | "high" | "medium" | "low"; - if (isSpof) level = "critical"; - else if (cfg.requiredDVNCount < 2) level = "high"; - else if (totalDvns < 3) level = "medium"; - else level = "low"; - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - indexed: true, - source: "live-on-chain-getConfig", - verification_protocol: "LayerZero V2 DVN", - dvn_config: { - required_dvn_count: cfg.requiredDVNCount, - optional_dvn_count: cfg.optionalDVNCount, - optional_dvn_threshold: cfg.optionalDVNThreshold, - required_dvns: cfg.requiredDVNs, - optional_dvns: cfg.optionalDVNs, - required_dvns_reputable: [], - }, - confirmations: cfg.confirmations.toString(), - total_dvn_count: totalDvns, - reputable_dvn_count: 0, - reputable_dvn_ratio: 0, - is_single_point_of_failure: isSpof, - spof_modes: isSpof - ? ["single_required_dvn", "no_optional_dvns", "no_threshold_redundancy"] - : [], - historical_incidents_count: 0, - historical_incidents_recent_year: 0, - last_incident: null, - risk_level: level, - v0_2_status: "experimental", - v0_2_note: - "Live on-chain read of LayerZero V2 endpoint.getConfig. DVN-reputability not classified for live reads in v0.2 (requires DVN-address-to-name registry). Used when target is not in Strale's curated seed; treat verdict severity weighting accordingly.", - }, - provenance: { - source: "live-on-chain-getConfig", - fetched_at: now, - endpoint_contract: live.endpoint, - rpc_used: live.rpc_used, - }, - }; - } - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - indexed: false, - source: "strale-curated-layerzero-seed", - live_read_attempted: true, - live_read_error: live.error ?? "unknown", - note: - "Bridge configuration not in Strale's curated LayerZero seed; live on-chain read attempted but did not return a decodable UlnConfig. Target may not be a LayerZero V2 OApp, or RPC may be temporarily unavailable. Treat as 'unknown', not 'safe'.", - }, - provenance: { - source: "strale-curated-layerzero-seed", - fetched_at: now, - }, - }; - } - - const totalDvns = - entry.dvn_config.required_dvns.length + - entry.dvn_config.optional_dvns.length; - const reputableRatio = totalDvns > 0 ? entry.reputable_dvn_count / totalDvns : 0; - - let riskLevel: "critical" | "high" | "medium" | "low"; - if (entry.is_single_point_of_failure) { - riskLevel = "critical"; - } else if (entry.dvn_config.required_dvn_count < 2) { - riskLevel = "high"; - } else if (reputableRatio < 0.5) { - riskLevel = "medium"; - } else { - riskLevel = "low"; - } - - const recentIncidents = entry.historical_incidents.filter((inc) => { - const days = - (Date.now() - new Date(inc.date).getTime()) / (86400 * 1000); - return days < 365; - }); - - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - indexed: true, - protocol_name: entry.protocol_name, - category: entry.category, - verification_protocol: "LayerZero V2 DVN", - dvn_config: { - required_dvn_count: entry.dvn_config.required_dvn_count, - optional_dvn_count: entry.dvn_config.optional_dvn_count, - optional_dvn_threshold: entry.dvn_config.optional_dvn_threshold, - required_dvns: entry.dvn_config.required_dvns, - optional_dvns: entry.dvn_config.optional_dvns, - required_dvns_reputable: entry.dvn_config.required_dvns.filter((d) => isReputableDvn(d)), - }, - total_dvn_count: totalDvns, - reputable_dvn_count: entry.reputable_dvn_count, - reputable_dvn_ratio: Math.round(reputableRatio * 100) / 100, - is_single_point_of_failure: entry.is_single_point_of_failure, - spof_modes: entry.spof_modes, - historical_incidents_count: entry.historical_incidents.length, - historical_incidents_recent_year: recentIncidents.length, - last_incident: entry.historical_incidents[0] ?? null, - risk_level: riskLevel, - config_last_verified_at: entry.config_last_verified_at, - notes: entry.notes, - }, - provenance: { - source: "strale-curated-layerzero-seed", - fetched_at: now, - }, - }; - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/bytecode-similarity.ts b/apps/api/src/web3-assurance/evaluators/bytecode-similarity.ts deleted file mode 100644 index f7625538..00000000 --- a/apps/api/src/web3-assurance/evaluators/bytecode-similarity.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Web3 Assurance — bytecode-similarity rug detector (v0.1 exact-match). - * - * Tier-2 moat from the strategic memo: hash deployed bytecode after - * normalizing the metadata block, compare against a curated set of - * known-rug bytecode hashes. v0.1 ships exact-match against a small - * seed; v0.2 adds fuzzy matching (n-gram or Jaccard on opcode sequences) - * so copy-paste rugs with minor parameter changes still match. - * - * Honest v0.1 scope: the seed in data/known-rug-bytecodes.ts is empty. - * The evaluator computes the hash and surfaces it as evidence; lookup - * returns no_match until the seed is populated. This means the - * infrastructure (RPC fetch + normalization + hashing + lookup) is in - * place and the moat begins compounding the moment the first rug is - * indexed. - * - * Compounds: every new rug Strale hashes adds an entry. Competitors - * cannot replicate without paying the same compute + curation cost. - */ - -import { createHash } from "node:crypto"; -import { registerEvaluator } from "./index.js"; -import { getEthRpcEndpoints } from "../../lib/eth-rpc-endpoints.js"; -import { - lookupRugBytecode, - getRugBytecodeCount, -} from "../data/known-rug-bytecodes.js"; -import type { Evaluator } from "../types.js"; - -const TIMEOUT_MS = 5000; - -const SUPPORTED_CHAINS = new Set(["ethereum", "base", "polygon", "arbitrum", "optimism"]); - -function stripMetadataBlock(bytecode: string): string { - const hex = bytecode.startsWith("0x") ? bytecode.slice(2) : bytecode; - if (hex.length < 6) return hex.toLowerCase(); - const lastTwoBytes = hex.slice(-4); - const len = parseInt(lastTwoBytes, 16); - if (Number.isNaN(len) || len === 0) return hex.toLowerCase(); - const metadataLen = (len + 2) * 2; - if (hex.length < metadataLen + 2) return hex.toLowerCase(); - return hex.slice(0, hex.length - metadataLen).toLowerCase(); -} - -function sha256(input: string): string { - return createHash("sha256").update(input).digest("hex"); -} - -async function fetchCode(rpc: string, address: string): Promise { - try { - const response = await fetch(rpc, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "eth_getCode", - params: [address.toLowerCase(), "latest"], - }), - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) return null; - const result = (await response.json()) as { result?: string; error?: unknown }; - if (result.error) return null; - return result.result ?? null; - } catch { - return null; - } -} - -const evaluator: Evaluator = { - name: "bytecode-similarity", - priority: "opportunistic", - appliesTo: (ctx) => - (ctx.targetType === "contract" || ctx.targetType === "token") && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target) && - SUPPORTED_CHAINS.has(ctx.chain.toLowerCase()), - cacheTTLSeconds: 604800, - cacheKey: (ctx) => `bytecode-sim:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - - if (ctx.chain.toLowerCase() !== "ethereum") { - return { - ok: true, - evidence: { - target: ctx.target, - chain_supported: false, - chain: ctx.chain, - note: "v0.1 supports Ethereum mainnet only via existing RPC substrate.", - }, - provenance: { source: "strale-curated-rug-bytecode-index", fetched_at: now }, - }; - } - - const endpoints = getEthRpcEndpoints(); - if (endpoints.length === 0) { - return { - ok: true, - evidence: { - target: ctx.target, - enabled: false, - note: "No RPC endpoint configured.", - }, - provenance: { source: "strale-curated-rug-bytecode-index", fetched_at: now }, - }; - } - - let code: string | null = null; - for (const rpc of endpoints) { - code = await fetchCode(rpc, ctx.target); - if (code !== null) break; - } - - if (!code || code === "0x") { - return { - ok: true, - evidence: { - target: ctx.target, - has_code: false, - note: "No bytecode at address (EOA, self-destructed, or never deployed).", - }, - provenance: { source: "strale-curated-rug-bytecode-index", fetched_at: now }, - }; - } - - const normalized = stripMetadataBlock(code); - const hash = sha256(normalized); - const match = lookupRugBytecode(hash); - - return { - ok: true, - evidence: { - target: ctx.target, - has_code: true, - bytecode_sha256: hash, - normalized_bytecode_length: normalized.length / 2, - seed_size: getRugBytecodeCount(), - match_found: match !== null, - match: match - ? { - pattern_name: match.pattern_name, - first_seen_address: match.first_seen_address, - first_seen_at: match.first_seen_at, - classification: match.classification, - amount_lost_usd_estimate: match.amount_lost_usd_estimate, - notes: match.notes, - } - : null, - v0_2_planned: - "fuzzy similarity matching (n-gram / Jaccard on opcode sequences) so copy-paste rugs with minor parameter changes still match", - }, - provenance: { source: "strale-curated-rug-bytecode-index", fetched_at: now }, - }; - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/cross-protocol-exposure.ts b/apps/api/src/web3-assurance/evaluators/cross-protocol-exposure.ts deleted file mode 100644 index 5910d137..00000000 --- a/apps/api/src/web3-assurance/evaluators/cross-protocol-exposure.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Web3 Assurance — cross-protocol exposure evaluator. - * - * Tier-2 moat from the 2026-05-01 strategic deep-dive: surface the - * composability blast radius of a target. The KelpDAO failure cascaded - * to $177M of Aave bad debt because rsETH was used as collateral elsewhere. - * Pre-transaction, no counterparty-assurance product surfaces *what - * downstream protocols you become indirectly exposed to* if the target - * fails. - * - * v0.1 surfaces three first-order dependencies via DefiLlama free data: - * - parent_protocol (the protocol family, e.g. Aave V2 -> Aave) - * - forked_from (lineage; if the parent has a documented exploit, your - * fork inherits the design) - * - oracle_dependencies (Chainlink / Pyth / unknown / sketchy) - * - * v0.2 will add multi-hop traversal (DefiLlama protocol-token holdings - * + cross-protocol stable/LST/LRT relationships) for the recursive case - * (rsETH -> stETH -> Lido validators -> Ethereum staking). - * - * Free no-key data; existing DefiLlama integration reused. - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator, EvaluatorContext } from "../types.js"; - -const PROTOCOL_DETAIL_API = "https://api.llama.fi/protocol"; -const PROTOCOLS_LIST_API = "https://api.llama.fi/protocols"; -const HACKS_API = "https://api.llama.fi/hacks"; -const TIMEOUT_MS = 8000; - -const REPUTABLE_ORACLES = new Set([ - "Chainlink", - "Pyth", - "RedStone", - "API3", - "UMA", - "Tellor", -]); - -interface DefiLlamaProtocolListEntry { - id: string; - name: string; - slug: string; - address?: string; - oracles?: string[]; - forkedFrom?: string[]; - parentProtocol?: string; -} - -interface DefiLlamaProtocolDetail { - id: string; - name: string; - slug: string; - oracles?: string[]; - forkedFrom?: string[]; - parentProtocol?: string; - category?: string; - audit_links?: string[]; -} - -interface DefiLlamaHack { - date: number; - name: string; - classification: string; - amount: number; - defillamaId?: string; -} - -let listCache: { protocols: DefiLlamaProtocolListEntry[]; ts: number } | null = null; -let hacksCache: { hacks: DefiLlamaHack[]; ts: number } | null = null; -const CACHE_TTL_MS = 30 * 60 * 1000; - -async function fetchProtocolList(): Promise { - if (listCache && Date.now() - listCache.ts < CACHE_TTL_MS) return listCache.protocols; - const response = await fetch(PROTOCOLS_LIST_API, { - headers: { "User-Agent": "Strale/1.0" }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`DefiLlama protocols HTTP ${response.status}`); - const data = (await response.json()) as DefiLlamaProtocolListEntry[]; - listCache = { protocols: data, ts: Date.now() }; - return data; -} - -async function fetchHacks(): Promise { - if (hacksCache && Date.now() - hacksCache.ts < CACHE_TTL_MS) return hacksCache.hacks; - const response = await fetch(HACKS_API, { - headers: { "User-Agent": "Strale/1.0" }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`DefiLlama hacks HTTP ${response.status}`); - const data = (await response.json()) as DefiLlamaHack[]; - hacksCache = { hacks: data, ts: Date.now() }; - return data; -} - -async function fetchProtocolDetail(slug: string): Promise { - try { - const response = await fetch(`${PROTOCOL_DETAIL_API}/${encodeURIComponent(slug)}`, { - headers: { "User-Agent": "Strale/1.0" }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) return null; - return (await response.json()) as DefiLlamaProtocolDetail; - } catch { - return null; - } -} - -function findProtocol( - protocols: DefiLlamaProtocolListEntry[], - target: string, -): DefiLlamaProtocolListEntry | null { - const normalized = target.toLowerCase().trim(); - return ( - protocols.find((p) => p.address && p.address.toLowerCase() === normalized) ?? - protocols.find((p) => p.slug?.toLowerCase() === normalized) ?? - protocols.find((p) => p.name?.toLowerCase() === normalized) ?? - protocols.find( - (p) => - p.slug?.toLowerCase().startsWith(`${normalized}-`) || - p.name?.toLowerCase().startsWith(`${normalized} `), - ) ?? - null - ); -} - -function classifyOracle(oracle: string): "reputable" | "unknown" { - return REPUTABLE_ORACLES.has(oracle) ? "reputable" : "unknown"; -} - -function findRelatedHacks( - hacks: DefiLlamaHack[], - candidates: string[], -): DefiLlamaHack[] { - const lowerCandidates = candidates.map((c) => c.toLowerCase()); - return hacks.filter((h) => - lowerCandidates.includes(h.name.toLowerCase()), - ); -} - -const evaluator: Evaluator = { - name: "cross-protocol-exposure", - priority: "opportunistic", - appliesTo: (ctx: EvaluatorContext) => - ctx.targetType === "protocol" || ctx.targetType === "contract", - cacheTTLSeconds: 1800, - cacheKey: (ctx) => `cross-exposure:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - try { - const [protocols, hacks] = await Promise.all([fetchProtocolList(), fetchHacks()]); - const listEntry = findProtocol(protocols, ctx.target); - - if (!listEntry) { - return { - ok: true, - evidence: { - target: ctx.target, - found: false, - note: "Target not in DefiLlama. v0.2 will fall back to on-chain composability traversal for unindexed contracts.", - }, - provenance: { source: "api.llama.fi", fetched_at: now }, - }; - } - - const detail = await fetchProtocolDetail(listEntry.slug); - const oracles = detail?.oracles ?? listEntry.oracles ?? []; - const forkedFrom = detail?.forkedFrom ?? listEntry.forkedFrom ?? []; - const parent = detail?.parentProtocol ?? listEntry.parentProtocol ?? null; - - const reputableOracles = oracles.filter((o) => classifyOracle(o) === "reputable"); - const unknownOracles = oracles.filter((o) => classifyOracle(o) === "unknown"); - - const exposureCandidates: string[] = [ - ...(parent ? [parent] : []), - ...forkedFrom, - ...oracles, - ]; - const relatedHacks = findRelatedHacks(hacks, exposureCandidates); - const recentHack = relatedHacks - .sort((a, b) => b.date - a.date) - .find((h) => (Date.now() - h.date * 1000) / (86400 * 1000) < 365); - - let exposureRiskLevel: "critical" | "high" | "medium" | "low" | "unknown"; - if (recentHack) { - const daysAgo = Math.floor( - (Date.now() - recentHack.date * 1000) / (86400 * 1000), - ); - exposureRiskLevel = daysAgo < 90 ? "critical" : "high"; - } else if (oracles.length === 0 && (forkedFrom.length > 0 || parent)) { - exposureRiskLevel = "medium"; - } else if (unknownOracles.length > reputableOracles.length) { - exposureRiskLevel = "medium"; - } else if (oracles.length > 0 && reputableOracles.length === oracles.length) { - exposureRiskLevel = "low"; - } else { - exposureRiskLevel = "unknown"; - } - - return { - ok: true, - evidence: { - target: ctx.target, - found: true, - protocol_name: listEntry.name, - parent_protocol: parent, - forked_from: forkedFrom, - oracle_dependencies: oracles, - reputable_oracles: reputableOracles, - unknown_oracles: unknownOracles, - related_hacks_count: relatedHacks.length, - last_related_hack: recentHack - ? { - name: recentHack.name, - date: new Date(recentHack.date * 1000).toISOString(), - classification: recentHack.classification, - amount_usd: recentHack.amount, - } - : null, - exposure_risk_level: exposureRiskLevel, - composability_chain_depth: 1, - v0_2_planned: "multi-hop traversal via DefiLlama protocol-token holdings", - }, - provenance: { - source: "api.llama.fi", - fetched_at: now, - endpoints: ["protocols", "protocol/{slug}", "hacks"], - }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "api.llama.fi", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/defillama-bridges.ts b/apps/api/src/web3-assurance/evaluators/defillama-bridges.ts deleted file mode 100644 index a3966bb0..00000000 --- a/apps/api/src/web3-assurance/evaluators/defillama-bridges.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Web3 Assurance — DefiLlama bridges evaluator. - * - * Surfaces bridge-specific risk for cross-chain transactions: total volume, - * deposit / withdrawal balance, exploit history, and security model - * classification (where DefiLlama exposes it). - * - * Free, no API key. Complements protocol-risk for the bridge target type. - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator } from "../types.js"; - -const TIMEOUT_MS = 8000; -const BRIDGES_API = "https://bridges.llama.fi/bridges"; - -interface DefiLlamaBridge { - id: number | string; - name: string; - displayName?: string; - chains?: string[]; - destinationChain?: string; - lastDailyVolume?: number; - monthlyVolume?: number; - lastHourlyVolume?: number; - currentDayTxs?: number; -} - -let cache: { bridges: DefiLlamaBridge[]; ts: number } | null = null; -const CACHE_TTL_MS = 60 * 60 * 1000; - -async function fetchBridges(): Promise { - if (cache && Date.now() - cache.ts < CACHE_TTL_MS) return cache.bridges; - const response = await fetch(BRIDGES_API, { - headers: { "User-Agent": "Strale/1.0" }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`DefiLlama bridges HTTP ${response.status}`); - const json = (await response.json()) as { bridges?: DefiLlamaBridge[] }; - const bridges = json.bridges ?? []; - cache = { bridges, ts: Date.now() }; - return bridges; -} - -function findBridge(bridges: DefiLlamaBridge[], target: string): DefiLlamaBridge | null { - const normalized = target.toLowerCase().trim(); - return ( - bridges.find((b) => b.name?.toLowerCase() === normalized) ?? - bridges.find((b) => b.displayName?.toLowerCase() === normalized) ?? - bridges.find( - (b) => - b.name?.toLowerCase().startsWith(normalized) || - b.displayName?.toLowerCase().startsWith(normalized), - ) ?? - null - ); -} - -const evaluator: Evaluator = { - name: "bridge-legitimacy", - priority: "opportunistic", - appliesTo: (ctx) => ctx.targetType === "bridge", - cacheTTLSeconds: 3600, - cacheKey: (ctx) => `bridge:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - try { - const bridges = await fetchBridges(); - const bridge = findBridge(bridges, ctx.target); - - if (!bridge) { - return { - ok: true, - evidence: { - target: ctx.target, - found: false, - note: "Bridge not found in DefiLlama bridges database. Either too new, too small, or not tracked.", - }, - provenance: { source: "bridges.llama.fi", fetched_at: now }, - }; - } - - return { - ok: true, - evidence: { - target: ctx.target, - found: true, - bridge_name: bridge.name, - display_name: bridge.displayName ?? null, - chains: bridge.chains ?? [], - destination_chain: bridge.destinationChain ?? null, - last_daily_volume_usd: bridge.lastDailyVolume ?? null, - monthly_volume_usd: bridge.monthlyVolume ?? null, - current_day_txs: bridge.currentDayTxs ?? null, - }, - provenance: { source: "bridges.llama.fi", fetched_at: now }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "bridges.llama.fi", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/defillama-protocol.ts b/apps/api/src/web3-assurance/evaluators/defillama-protocol.ts deleted file mode 100644 index 806a2ee4..00000000 --- a/apps/api/src/web3-assurance/evaluators/defillama-protocol.ts +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Web3 Assurance — DefiLlama protocol risk evaluator. - * - * Free, no API key, no rate limit. The single most useful free source in - * crypto: 7,000+ protocols, 500+ chains, $140B+ TVL tracked, exploits DB. - * - * For a contract / protocol / token target, this evaluator finds the protocol - * (by contract address or slug match) and surfaces: - * - TVL trend (current, change_1d, change_7d, change_1m) - * - Hack/exploit history (severity, dates, recovery) - * - Governance signal (audit list, oracle list, listed_at) - * - Treasury / forks / parent protocol context - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator, EvaluatorContext } from "../types.js"; - -const PROTOCOLS_API = "https://api.llama.fi/protocols"; -const HACKS_API = "https://api.llama.fi/hacks"; -const TIMEOUT_MS = 8000; - -interface DefiLlamaProtocol { - id: string; - name: string; - slug: string; - tvl: number; - change_1d?: number; - change_7d?: number; - change_1m?: number; - category?: string; - chains?: string[]; - audits?: string; - audit_links?: string[]; - oracles?: string[]; - forkedFrom?: string[]; - listedAt?: number; - address?: string; - symbol?: string; -} - -interface DefiLlamaHack { - date: number; - name: string; - classification: string; - technique?: string; - amount: number; - source?: string; - returnedFunds?: number; - chain?: string; - defillamaId?: string; -} - -let protocolCache: { protocols: DefiLlamaProtocol[]; ts: number } | null = null; -let hackCache: { hacks: DefiLlamaHack[]; ts: number } | null = null; -const CACHE_TTL_MS = 30 * 60 * 1000; - -async function fetchProtocols(): Promise { - if (protocolCache && Date.now() - protocolCache.ts < CACHE_TTL_MS) { - return protocolCache.protocols; - } - const response = await fetch(PROTOCOLS_API, { - headers: { "User-Agent": "Strale/1.0" }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`DefiLlama protocols HTTP ${response.status}`); - const data = (await response.json()) as DefiLlamaProtocol[]; - protocolCache = { protocols: data, ts: Date.now() }; - return data; -} - -async function fetchHacks(): Promise { - if (hackCache && Date.now() - hackCache.ts < CACHE_TTL_MS) { - return hackCache.hacks; - } - const response = await fetch(HACKS_API, { - headers: { "User-Agent": "Strale/1.0" }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`DefiLlama hacks HTTP ${response.status}`); - const data = (await response.json()) as DefiLlamaHack[]; - hackCache = { hacks: data, ts: Date.now() }; - return data; -} - -function findProtocol( - protocols: DefiLlamaProtocol[], - target: string, -): { protocol: DefiLlamaProtocol | null; ambiguous_candidates?: string[] } { - const normalized = target.toLowerCase().trim(); - const byAddress = protocols.find( - (p) => p.address && p.address.toLowerCase() === normalized, - ); - if (byAddress) return { protocol: byAddress }; - const bySlug = protocols.find((p) => p.slug?.toLowerCase() === normalized); - if (bySlug) return { protocol: bySlug }; - const byName = protocols.find((p) => p.name?.toLowerCase() === normalized); - if (byName) return { protocol: byName }; - - const familyMatches = protocols.filter( - (p) => - p.slug?.toLowerCase().startsWith(`${normalized}-`) || - p.slug?.toLowerCase().startsWith(`${normalized}_`) || - p.name?.toLowerCase().startsWith(`${normalized} `), - ); - if (familyMatches.length === 1) return { protocol: familyMatches[0] }; - if (familyMatches.length > 1) { - const sorted = familyMatches.sort((a, b) => (b.tvl ?? 0) - (a.tvl ?? 0)); - return { - protocol: sorted[0], - ambiguous_candidates: sorted.slice(0, 5).map((p) => p.slug ?? p.name ?? ""), - }; - } - - return { protocol: null }; -} - -function findRelevantHacks( - hacks: DefiLlamaHack[], - protocolName: string | null, - protocolId: string | null, -): DefiLlamaHack[] { - if (!protocolName && !protocolId) return []; - return hacks.filter((h) => { - if (protocolId && h.defillamaId === protocolId) return true; - if (protocolName && h.name.toLowerCase() === protocolName.toLowerCase()) return true; - return false; - }); -} - -const evaluator: Evaluator = { - name: "protocol-risk", - priority: "opportunistic", - appliesTo: (ctx: EvaluatorContext) => - ctx.targetType === "protocol" || ctx.targetType === "contract", - cacheTTLSeconds: 3600, - cacheKey: (ctx) => `protocol-risk:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - try { - const [protocols, hacks] = await Promise.all([fetchProtocols(), fetchHacks()]); - const { protocol, ambiguous_candidates } = findProtocol(protocols, ctx.target); - - if (!protocol) { - return { - ok: true, - evidence: { - target: ctx.target, - found: false, - note: "Protocol not found in DefiLlama database. Either too new, too small (<$1M TVL typical threshold), or not on a tracked chain.", - }, - provenance: { source: "api.llama.fi", fetched_at: now }, - }; - } - - const relevantHacks = findRelevantHacks(hacks, protocol.name, protocol.id); - const lastHack = relevantHacks.length > 0 - ? relevantHacks.sort((a, b) => b.date - a.date)[0] - : null; - const totalLost = relevantHacks.reduce((sum, h) => sum + (h.amount ?? 0), 0); - const totalRecovered = relevantHacks.reduce((sum, h) => sum + (h.returnedFunds ?? 0), 0); - - const daysSinceLastIncident = lastHack - ? Math.floor((Date.now() - lastHack.date * 1000) / (86400 * 1000)) - : null; - - const audits = (protocol.audit_links ?? []).length; - const oracles = (protocol.oracles ?? []).length; - - let riskLevel: "low" | "medium" | "high" | "unknown"; - if (relevantHacks.length === 0 && audits > 0) riskLevel = "low"; - else if (lastHack && daysSinceLastIncident !== null && daysSinceLastIncident < 90) riskLevel = "high"; - else if (relevantHacks.length > 0) riskLevel = "medium"; - else if (audits === 0) riskLevel = "medium"; - else riskLevel = "low"; - - return { - ok: true, - evidence: { - target: ctx.target, - found: true, - ambiguous_match: !!ambiguous_candidates, - ambiguous_candidates: ambiguous_candidates ?? null, - protocol_name: protocol.name, - protocol_slug: protocol.slug, - category: protocol.category ?? null, - chains: protocol.chains ?? [], - tvl_usd: protocol.tvl, - tvl_change_1d_pct: protocol.change_1d ?? null, - tvl_change_7d_pct: protocol.change_7d ?? null, - tvl_change_1m_pct: protocol.change_1m ?? null, - listed_at: protocol.listedAt ? new Date(protocol.listedAt * 1000).toISOString() : null, - audits_count: audits, - oracles: protocol.oracles ?? [], - forked_from: protocol.forkedFrom ?? [], - incidents: { - count: relevantHacks.length, - total_lost_usd: totalLost, - total_recovered_usd: totalRecovered, - last_incident: lastHack - ? { - date: new Date(lastHack.date * 1000).toISOString(), - classification: lastHack.classification, - technique: lastHack.technique ?? null, - amount_usd: lastHack.amount, - source: lastHack.source ?? null, - } - : null, - days_since_last_incident: daysSinceLastIncident, - }, - risk_level: riskLevel, - }, - provenance: { - source: "api.llama.fi", - fetched_at: now, - endpoints: ["protocols", "hacks"], - }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "api.llama.fi", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/eas-attestations.ts b/apps/api/src/web3-assurance/evaluators/eas-attestations.ts deleted file mode 100644 index b68ed5fd..00000000 --- a/apps/api/src/web3-assurance/evaluators/eas-attestations.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * Web3 Assurance — EAS (Ethereum Attestation Service) reader. - * - * EAS is a free, tokenless, public-good infrastructure for on-chain - * attestations. Anyone can attest anything about any address using any - * schema. v1 reads attestation count + summary by querying EAS's GraphQL - * indexer (free, public). - * - * v1 surfaces: - * - total attestations about this address (as recipient) - * - revoked count - * - top schemas attested under - * - attesters (top 5 by frequency) - * - * v1.5 will add schema-aware interpretation (e.g. "verified-by-Coinbase" - * schema → trust signal); deferred until specific schemas earn enough - * adoption to be worth hardcoding. - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator } from "../types.js"; - -const TIMEOUT_MS = 6000; - -const EAS_GRAPHQL_BY_CHAIN: Record = { - ethereum: "https://easscan.org/graphql", - base: "https://base.easscan.org/graphql", - optimism: "https://optimism.easscan.org/graphql", - arbitrum: "https://arbitrum.easscan.org/graphql", - "1": "https://easscan.org/graphql", - "8453": "https://base.easscan.org/graphql", - "10": "https://optimism.easscan.org/graphql", - "42161": "https://arbitrum.easscan.org/graphql", -}; - -const QUERY = ` - query Attestations($recipient: String!) { - attestations( - where: { recipient: { equals: $recipient } } - orderBy: { time: desc } - take: 100 - ) { - id - schemaId - attester - revoked - revocationTime - time - schema { schemaNames { name } } - } - } -`; - -const evaluator: Evaluator = { - name: "eas-attestations", - priority: "opportunistic", - appliesTo: (ctx) => - /^0x[a-fA-F0-9]{40}$/.test(ctx.target), - cacheTTLSeconds: 1800, - cacheKey: (ctx) => `eas:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const endpoint = EAS_GRAPHQL_BY_CHAIN[ctx.chain.toLowerCase()]; - if (!endpoint) { - return { - ok: true, - evidence: { - target: ctx.target, - chain_supported: false, - chain: ctx.chain, - note: "EAS not deployed on this chain (or not yet integrated by Strale).", - }, - provenance: { source: "easscan.org", fetched_at: now }, - }; - } - - try { - const response = await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json", "User-Agent": "Strale/1.0" }, - body: JSON.stringify({ query: QUERY, variables: { recipient: ctx.target } }), - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - - if (!response.ok) throw new Error(`EAS HTTP ${response.status}`); - - const data = (await response.json()) as { - data?: { - attestations?: Array<{ - schemaId: string; - attester: string; - revoked: boolean; - time: number; - schema?: { schemaNames?: Array<{ name: string }> }; - }>; - }; - }; - - const attestations = data?.data?.attestations ?? []; - const total = attestations.length; - const revoked = attestations.filter((a) => a.revoked).length; - - const schemaCounts: Record = {}; - const attesterCounts: Record = {}; - for (const a of attestations) { - const schemaName = a.schema?.schemaNames?.[0]?.name ?? a.schemaId; - schemaCounts[schemaName] = (schemaCounts[schemaName] ?? 0) + 1; - attesterCounts[a.attester] = (attesterCounts[a.attester] ?? 0) + 1; - } - const topSchemas = Object.entries(schemaCounts) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([name, count]) => ({ name, count })); - const topAttesters = Object.entries(attesterCounts) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([address, count]) => ({ address, count })); - - const oldest = attestations.length > 0 - ? attestations.sort((a, b) => a.time - b.time)[0] - : null; - - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - total_attestations: total, - revoked_count: revoked, - first_attestation_at: oldest ? new Date(oldest.time * 1000).toISOString() : null, - top_schemas: topSchemas, - top_attesters: topAttesters, - truncated_at_100: total === 100, - }, - provenance: { source: "easscan.org", fetched_at: now, endpoint }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "easscan.org", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/erc-8004-reputation.ts b/apps/api/src/web3-assurance/evaluators/erc-8004-reputation.ts deleted file mode 100644 index 39d0c1f4..00000000 --- a/apps/api/src/web3-assurance/evaluators/erc-8004-reputation.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Web3 Assurance — ERC-8004 trustless agent reputation reader. - * - * ERC-8004 is the on-chain identity / reputation standard for AI agents, - * co-authored by Google, Coinbase, MetaMask, and the Ethereum Foundation. - * v1 reads: - * - whether the address is registered as an ERC-8004 agent - * - reputation pointer (URI to off-chain reputation document) - * - validators / endorsers if exposed by the registry - * - * Implementation notes: - * ERC-8004 deployments are still finalising contract addresses across - * chains. v1 takes the registry contract address from env so we don't - * hardcode a moving target. If the env is absent, the evaluator returns - * a "not configured" evidence block — composer treats this as - * non-blocking. - * - * Per CLAUDE.md DEC-20260428-B engineering bar, we do not assert that - * absence of an ERC-8004 record means anything negative — the verdict - * logic treats this as a positive-signal-only evidence type. - */ - -import { registerEvaluator } from "./index.js"; -import { getEthRpcEndpoints } from "../../lib/eth-rpc-endpoints.js"; -import type { Evaluator } from "../types.js"; - -const TIMEOUT_MS = 5000; - -const REGISTRY_BY_CHAIN: Record = { - ethereum: process.env.ERC8004_REGISTRY_ETH ?? "", - base: process.env.ERC8004_REGISTRY_BASE ?? "", - arbitrum: process.env.ERC8004_REGISTRY_ARB ?? "", -}; - -const RESOLVE_SELECTOR = "0xb8c2bcf8"; - -async function ethCall( - rpc: string, - to: string, - data: string, -): Promise { - try { - const response = await fetch(rpc, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "eth_call", - params: [{ to, data }, "latest"], - }), - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) return null; - const result = (await response.json()) as { result?: string; error?: unknown }; - if (result.error) return null; - return result.result ?? null; - } catch { - return null; - } -} - -const evaluator: Evaluator = { - name: "erc-8004-reputation", - priority: "opportunistic", - appliesTo: (ctx) => - /^0x[a-fA-F0-9]{40}$/.test(ctx.target) && - (ctx.targetType === "wallet" || ctx.targetType === "contract"), - cacheTTLSeconds: 1800, - cacheKey: (ctx) => `erc8004:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const registry = REGISTRY_BY_CHAIN[ctx.chain.toLowerCase()]; - - if (!registry) { - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - registry_configured: false, - note: "ERC-8004 registry address not configured for this chain (env var ERC8004_REGISTRY_*). Returning positive-signal-only evidence; absence is not negative.", - }, - provenance: { source: "erc-8004-registry", fetched_at: now }, - }; - } - - const data = RESOLVE_SELECTOR + ctx.target.slice(2).padStart(64, "0").toLowerCase(); - - const endpoints = ctx.chain.toLowerCase() === "ethereum" ? getEthRpcEndpoints() : []; - if (endpoints.length === 0) { - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - registry_configured: true, - rpc_available: false, - note: "No RPC endpoint configured for this chain.", - }, - provenance: { source: "erc-8004-registry", fetched_at: now }, - }; - } - - let result: string | null = null; - for (const rpc of endpoints) { - result = await ethCall(rpc, registry, data); - if (result !== null) break; - } - - const isRegistered = !!result && result !== "0x" && !/^0x0+$/.test(result); - - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - registry, - is_registered: isRegistered, - raw_response: result, - note: isRegistered - ? "Address has an ERC-8004 reputation pointer registered." - : "No ERC-8004 reputation registered. Treated as neutral, not negative.", - }, - provenance: { source: "erc-8004-registry", fetched_at: now }, - }; - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/existing-cap.ts b/apps/api/src/web3-assurance/evaluators/existing-cap.ts deleted file mode 100644 index 4d1b815a..00000000 --- a/apps/api/src/web3-assurance/evaluators/existing-cap.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Web3 Assurance — wrappers around existing Strale capabilities. - * - * Strale already has 7+ live crypto capabilities (wallet-risk-score, - * wallet-age-check, wallet-transactions-lookup, token-security-check, - * contract-verify-check, approval-security-check, wallet-balance-lookup). - * The composer reuses them via getDirectExecutor — the Web3 Assurance call - * is one billing event for the customer; the underlying capabilities aren't - * billed separately. - */ - -import { getDirectExecutor, type CapabilityInput } from "../../capabilities/index.js"; -import { registerEvaluator } from "./index.js"; -import type { EvaluatorContext, Evaluator, EvaluatorPriority } from "../types.js"; - -interface WrapperOptions { - name: string; - capabilitySlug: string; - priority: EvaluatorPriority; - appliesTo: (ctx: EvaluatorContext) => boolean; - buildInput: (ctx: EvaluatorContext) => CapabilityInput; - cacheTTLSeconds: number; -} - -function makeWrapper(opts: WrapperOptions): Evaluator { - return { - name: opts.name, - priority: opts.priority, - appliesTo: opts.appliesTo, - cacheTTLSeconds: opts.cacheTTLSeconds, - cacheKey: (ctx) => `${opts.name}:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const exec = getDirectExecutor(opts.capabilitySlug); - if (!exec) { - return { - ok: false, - evidence: null, - provenance: { source: "internal", fetched_at: new Date().toISOString() }, - error: `Capability '${opts.capabilitySlug}' not registered`, - }; - } - try { - const input = opts.buildInput(ctx); - const result = await exec(input); - return { - ok: true, - evidence: result.output, - provenance: result.provenance, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { - source: opts.capabilitySlug, - fetched_at: new Date().toISOString(), - }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, - }; -} - -const isEvmChain = (chain: string): boolean => - ["1", "8453", "137", "42161", "10", "56", "43114", "ethereum", "base", "polygon", "arbitrum", "optimism"].includes( - chain.toLowerCase(), - ); - -const chainToId = (chain: string): string => { - const map: Record = { - ethereum: "1", - base: "8453", - polygon: "137", - arbitrum: "42161", - optimism: "10", - bsc: "56", - avalanche: "43114", - }; - return map[chain.toLowerCase()] ?? chain; -}; - -registerEvaluator( - makeWrapper({ - name: "wallet-identity", - capabilitySlug: "wallet-age-check", - priority: "opportunistic", - appliesTo: (ctx) => ctx.targetType === "wallet" && isEvmChain(ctx.chain), - buildInput: (ctx) => ({ address: ctx.target, chain_id: chainToId(ctx.chain) }), - cacheTTLSeconds: 86400, - }), -); - -registerEvaluator( - makeWrapper({ - name: "wallet-history-risk", - capabilitySlug: "wallet-risk-score", - priority: "critical", - appliesTo: (ctx) => ctx.targetType === "wallet" && isEvmChain(ctx.chain), - buildInput: (ctx) => ({ address: ctx.target, chain_id: chainToId(ctx.chain) }), - cacheTTLSeconds: 3600, - }), -); - -registerEvaluator( - makeWrapper({ - name: "wallet-transactions", - capabilitySlug: "wallet-transactions-lookup", - priority: "opportunistic", - appliesTo: (ctx) => ctx.targetType === "wallet" && isEvmChain(ctx.chain), - buildInput: (ctx) => ({ address: ctx.target, chain_id: chainToId(ctx.chain), limit: 20 }), - cacheTTLSeconds: 1800, - }), -); - -registerEvaluator( - makeWrapper({ - name: "wallet-balance", - capabilitySlug: "wallet-balance-lookup", - priority: "opportunistic", - appliesTo: (ctx) => ctx.targetType === "wallet" && isEvmChain(ctx.chain), - buildInput: (ctx) => ({ address: ctx.target, chain_id: chainToId(ctx.chain) }), - cacheTTLSeconds: 600, - }), -); - -registerEvaluator( - makeWrapper({ - name: "token-safety", - capabilitySlug: "token-security-check", - priority: "critical", - appliesTo: (ctx) => ctx.targetType === "token" && isEvmChain(ctx.chain), - buildInput: (ctx) => ({ contract_address: ctx.target, chain_id: chainToId(ctx.chain) }), - cacheTTLSeconds: 1800, - }), -); - -registerEvaluator( - makeWrapper({ - name: "contract-verification", - capabilitySlug: "contract-verify-check", - priority: "opportunistic", - appliesTo: (ctx) => - (ctx.targetType === "contract" || ctx.targetType === "token") && isEvmChain(ctx.chain), - buildInput: (ctx) => ({ contract_address: ctx.target, chain_id: chainToId(ctx.chain) }), - cacheTTLSeconds: 604800, - }), -); - -registerEvaluator( - makeWrapper({ - name: "approval-inventory", - capabilitySlug: "approval-security-check", - priority: "opportunistic", - appliesTo: (ctx) => ctx.targetType === "wallet" && isEvmChain(ctx.chain), - buildInput: (ctx) => ({ address: ctx.target, chain_id: chainToId(ctx.chain) }), - cacheTTLSeconds: 300, - }), -); - -/** - * Sanctions screening via Dilisense name search. - * - * Only fires for target types whose `ctx.target` is a human-readable - * string (protocol slug, domain). For hex-address targets — wallet, - * contract, token, bridge — name search against a 42-char `0x…` is a - * category error: it always returns zero matches and burns one - * Dilisense quota unit per call. The right primitive for hex-target - * sanctions screening is OFAC SDN's Specially Designated Nationals - * crypto-address list (a separate dataset, not yet wired into Strale). - * - * Until OFAC SDN crypto-address lookup ships, hex-address targets - * produce no sanctions evidence and `computeVerdict` honestly notes - * this in `suggested_action` ("sanctions evidence unavailable…"). - */ -registerEvaluator( - makeWrapper({ - name: "sanctions", - capabilitySlug: "sanctions-check", - priority: "critical", - appliesTo: (ctx) => ctx.targetType === "protocol" || ctx.targetType === "domain", - buildInput: (ctx) => ({ name: ctx.target, type: ctx.targetType }), - cacheTTLSeconds: 3600, - }), -); diff --git a/apps/api/src/web3-assurance/evaluators/index.ts b/apps/api/src/web3-assurance/evaluators/index.ts deleted file mode 100644 index f96c0ab4..00000000 --- a/apps/api/src/web3-assurance/evaluators/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Web3 Assurance — evaluator registry. - * - * Each evaluator is a self-contained module that returns one evidence section - * of the final answer. The composer fans them out in parallel and reconciles. - * - * Evaluators come in two flavors: - * 1. wrappers around existing Strale capabilities (wallet-risk-score, - * wallet-age-check, wallet-transactions-lookup, token-security-check, - * contract-verify-check, approval-security-check) - * 2. new modules built specifically for Web3 Assurance (DefiLlama, Sourcify, - * mixer-graded classifier, Tenderly simulation, EAS, ERC-8004, sister-rug, - * ScamSniffer, Web3 Antivirus, REKT, audit-firm aggregation) - * - * Sanctions evaluation is delegated to the Counterparty Assurance sanctions substrate - * via the existing sanctions-check capability — not duplicated here. - */ - -import type { Evaluator } from "../types.js"; - -const evaluators: Evaluator[] = []; - -export function registerEvaluator(evaluator: Evaluator): void { - if (evaluators.find((e) => e.name === evaluator.name)) { - throw new Error(`Evaluator '${evaluator.name}' already registered.`); - } - evaluators.push(evaluator); -} - -export function getEvaluators(): readonly Evaluator[] { - return evaluators; -} - -export function getEvaluator(name: string): Evaluator | undefined { - return evaluators.find((e) => e.name === name); -} diff --git a/apps/api/src/web3-assurance/evaluators/mixer-graded.ts b/apps/api/src/web3-assurance/evaluators/mixer-graded.ts deleted file mode 100644 index 0ae107ce..00000000 --- a/apps/api/src/web3-assurance/evaluators/mixer-graded.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Web3 Assurance — graded mixer / privacy-pool detection. - * - * Per the March 2025 OFAC Tornado Cash delist and the Treasury's March 2026 - * report acknowledging legitimate mixer use, this is NOT a binary "block". - * The evaluator returns a graded risk_weight per entry plus a regulatory - * status hint that's jurisdiction-aware. - * - * v1 implementation: direct address match against the curated list. v2: - * trace tx history N hops and surface mixer touches with hop distance. - * v2 deferred so v1 ships without expensive on-chain trace queries. - */ - -import { registerEvaluator } from "./index.js"; -import { lookupMixerAddress } from "../data/mixer-addresses.js"; -import type { Evaluator } from "../types.js"; - -const evaluator: Evaluator = { - name: "mixer-graded", - priority: "critical", - appliesTo: (ctx) => - ctx.targetType === "wallet" && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target), - cacheTTLSeconds: 14400, - cacheKey: (ctx) => `mixer:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const direct = lookupMixerAddress(ctx.target); - - if (!direct) { - return { - ok: true, - evidence: { - target: ctx.target, - is_known_mixer: false, - direct_match: false, - v2_trace_status: "deferred", - note: "Direct match only in v1. v2 will trace recent funding hops.", - }, - provenance: { source: "strale-curated-mixer-list", fetched_at: now }, - }; - } - - const jurisdictionNote = (() => { - const j = ctx.callerJurisdiction?.toUpperCase(); - if (!j) return "no caller jurisdiction supplied — applies most-restrictive interpretation"; - if (j === "US") { - return direct.category === "delisted" - ? "delisted by OFAC March 2025; still elevated risk under FinCEN BSA scrutiny" - : "active OFAC sanctions apply"; - } - if (j === "EU" || j === "EEA") { - return direct.category === "sanctioned" - ? "EU restrictive measures may apply" - : "no current EU listing — graded risk per Strale curated list"; - } - return "no jurisdiction-specific rule encoded; apply most-restrictive interpretation"; - })(); - - return { - ok: true, - evidence: { - target: ctx.target, - is_known_mixer: true, - direct_match: true, - service: direct.service, - category: direct.category, - risk_weight: direct.risk_weight, - regulatory_note: direct.notes, - jurisdiction_interpretation: jurisdictionNote, - v2_trace_status: "deferred", - }, - provenance: { source: "strale-curated-mixer-list", fetched_at: now }, - }; - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/rekt-database.ts b/apps/api/src/web3-assurance/evaluators/rekt-database.ts deleted file mode 100644 index 37862f7e..00000000 --- a/apps/api/src/web3-assurance/evaluators/rekt-database.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Web3 Assurance — REKT Database (de.fi) cross-reference. - * - * 2,500+ documented exploits, exit scams, and rug pulls. API access via - * de.fi/rekt-database is free on request (token-gated). Complements - * DefiLlama Hacks DB by including events DefiLlama doesn't track - * (exit scams, smaller protocols, off-chain rugs). - * - * v1 implementation: load REKT database snapshot at boot via env-supplied - * token; refresh every 6h. If REKT_API_TOKEN is unset, evaluator returns - * 'enabled: false' — verdict treats as neutral. - * - * For v0.1 alpha, ships in fallback mode (token unset) and returns - * "enabled: false" for now; live wiring follows token registration. - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator } from "../types.js"; - -const TIMEOUT_MS = 10000; -const REKT_API = "https://de.fi/api/rekt"; -const REFRESH_MS = 6 * 60 * 60 * 1000; - -interface RektEntry { - id: string | number; - project_name?: string; - contract_address?: string; - date?: string; - category?: string; - technical_issue?: string; - funds_lost_usd?: number; - funds_returned_usd?: number; -} - -interface RektCache { - byProject: Map; - byAddress: Map; - fetched_at: string; - ts: number; -} - -let cache: RektCache | null = null; -let inFlight: Promise | null = null; - -async function loadIndex(): Promise { - const token = process.env.REKT_API_TOKEN; - if (!token) return null; - - if (cache && Date.now() - cache.ts < REFRESH_MS) return cache; - if (inFlight) return inFlight; - - inFlight = (async () => { - try { - const response = await fetch(REKT_API, { - headers: { Authorization: `Bearer ${token}`, "User-Agent": "Strale/1.0" }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`REKT HTTP ${response.status}`); - const entries = (await response.json()) as RektEntry[]; - - const byProject = new Map(); - const byAddress = new Map(); - for (const entry of entries) { - if (entry.project_name) { - const key = entry.project_name.toLowerCase(); - (byProject.get(key) ?? byProject.set(key, []).get(key)!).push(entry); - } - if (entry.contract_address) { - const key = entry.contract_address.toLowerCase(); - (byAddress.get(key) ?? byAddress.set(key, []).get(key)!).push(entry); - } - } - - const next: RektCache = { - byProject, - byAddress, - fetched_at: new Date().toISOString(), - ts: Date.now(), - }; - cache = next; - return next; - } finally { - inFlight = null; - } - })(); - return inFlight; -} - -const evaluator: Evaluator = { - name: "rekt-database", - priority: "opportunistic", - appliesTo: (ctx) => - ctx.targetType === "protocol" || - ctx.targetType === "contract" || - ctx.targetType === "token" || - ctx.targetType === "bridge", - cacheTTLSeconds: 21600, - cacheKey: (ctx) => `rekt:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const index = await loadIndex(); - - if (!index) { - return { - ok: true, - evidence: { - target: ctx.target, - enabled: false, - note: "REKT Database integration disabled — REKT_API_TOKEN not set. Verdict treats as neutral; absence is not negative.", - }, - provenance: { source: "de.fi/rekt-database", fetched_at: now }, - }; - } - - const normalized = ctx.target.toLowerCase().trim(); - const matches = [ - ...(index.byAddress.get(normalized) ?? []), - ...(index.byProject.get(normalized) ?? []), - ]; - - if (matches.length === 0) { - return { - ok: true, - evidence: { - target: ctx.target, - found: false, - note: "No matching entries in REKT Database.", - }, - provenance: { source: "de.fi/rekt-database", fetched_at: now, list_fetched_at: index.fetched_at }, - }; - } - - const totalLost = matches.reduce((sum, m) => sum + (m.funds_lost_usd ?? 0), 0); - const totalReturned = matches.reduce((sum, m) => sum + (m.funds_returned_usd ?? 0), 0); - const sortedByDate = matches.sort((a, b) => - (b.date ?? "").localeCompare(a.date ?? ""), - ); - const lastEvent = sortedByDate[0]; - - return { - ok: true, - evidence: { - target: ctx.target, - found: true, - events_count: matches.length, - total_funds_lost_usd: totalLost, - total_funds_returned_usd: totalReturned, - last_event: { - project: lastEvent.project_name ?? null, - date: lastEvent.date ?? null, - category: lastEvent.category ?? null, - technical_issue: lastEvent.technical_issue ?? null, - funds_lost_usd: lastEvent.funds_lost_usd ?? null, - }, - }, - provenance: { source: "de.fi/rekt-database", fetched_at: now, list_fetched_at: index.fetched_at }, - }; - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/scamsniffer.ts b/apps/api/src/web3-assurance/evaluators/scamsniffer.ts deleted file mode 100644 index ba4c491e..00000000 --- a/apps/api/src/web3-assurance/evaluators/scamsniffer.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Web3 Assurance — ScamSniffer scam-cluster cross-reference. - * - * ScamSniffer maintains an open-source repository of phishing wallet addresses - * and phishing domains, refreshed daily (with a 7-day delay on the open feed; - * real-time premium tier deferred to Phase 4). - * - * v1 fetches the address blacklist from GitHub once per startup + every 6h, - * caches in memory. The list is small enough (~10k addresses) for in-memory - * lookup. - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator } from "../types.js"; - -const ADDRESS_LIST_URL = - "https://raw.githubusercontent.com/scamsniffer/scam-database/main/blacklist/address.json"; -const REFRESH_MS = 6 * 60 * 60 * 1000; -const FETCH_TIMEOUT_MS = 10000; - -interface ScamCache { - addresses: Set; - fetched_at: string; - ts: number; -} - -let cache: ScamCache | null = null; -let inFlight: Promise | null = null; - -async function loadList(): Promise { - if (cache && Date.now() - cache.ts < REFRESH_MS) return cache; - if (inFlight) return inFlight; - inFlight = (async () => { - try { - const response = await fetch(ADDRESS_LIST_URL, { - headers: { "User-Agent": "Strale/1.0" }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - }); - if (!response.ok) throw new Error(`ScamSniffer HTTP ${response.status}`); - const arr = (await response.json()) as string[]; - const next: ScamCache = { - addresses: new Set(arr.map((a) => a.toLowerCase())), - fetched_at: new Date().toISOString(), - ts: Date.now(), - }; - cache = next; - return next; - } finally { - inFlight = null; - } - })(); - return inFlight; -} - -const evaluator: Evaluator = { - name: "scam-cluster", - priority: "critical", - appliesTo: (ctx) => - ctx.targetType === "wallet" && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target), - cacheTTLSeconds: 21600, - cacheKey: (ctx) => `scamsniffer:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - try { - const list = await loadList(); - const isMatch = list.addresses.has(ctx.target.toLowerCase()); - return { - ok: true, - evidence: { - target: ctx.target, - is_scam_cluster: isMatch, - source: "scamsniffer/scam-database", - list_fetched_at: list.fetched_at, - list_size: list.addresses.size, - note: isMatch - ? "Address present on ScamSniffer phishing-address list. Open feed has 7-day delay; recent inclusion possible." - : "Address not on ScamSniffer phishing-address list. Open feed has 7-day delay; recent additions may not yet be reflected.", - }, - provenance: { - source: "github.com/scamsniffer/scam-database", - fetched_at: now, - }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { - source: "github.com/scamsniffer/scam-database", - fetched_at: now, - }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/sister-rug.ts b/apps/api/src/web3-assurance/evaluators/sister-rug.ts deleted file mode 100644 index 24b86b8c..00000000 --- a/apps/api/src/web3-assurance/evaluators/sister-rug.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Web3 Assurance — sister-rug detector. - * - * Identifies contracts where the SAME deployer launched prior contracts - * that were rugged or where the bytecode resembles a known rug pattern. - * - * v1 implementation focuses on the deployer-history axis (cheaper): - * 1. Pull deployer address via Etherscan getContractCreation - * 2. Look up other contracts the deployer has launched - * 3. Cross-reference each prior deployment against: - * - DefiLlama Hacks DB (loaded by defillama-protocol evaluator) - * - REKT Database (Phase 2) - * - ScamSniffer drainer list (loaded by scamsniffer evaluator) - * - * v1.5 adds bytecode similarity: hash deployed bytecode, compare against - * a curated set of known rug bytecode hashes. Deferred for v1 to keep - * scope tight. - * - * v1 returns: - * - deployer address - * - prior deployments count (capped at 50 most recent) - * - prior deployments flagged by any cross-reference - */ - -import { registerEvaluator } from "./index.js"; -import { etherscanFetch } from "../../capabilities/lib/etherscan-client.js"; -import type { Evaluator } from "../types.js"; - -const CHAIN_TO_ID: Record = { - ethereum: "1", - base: "8453", - polygon: "137", - arbitrum: "42161", - optimism: "10", - bsc: "56", -}; - -const evaluator: Evaluator = { - name: "sister-rug", - priority: "opportunistic", - appliesTo: (ctx) => - (ctx.targetType === "contract" || ctx.targetType === "token") && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target), - cacheTTLSeconds: 86400, - cacheKey: (ctx) => `sister-rug:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const chainId = CHAIN_TO_ID[ctx.chain.toLowerCase()]; - - if (!chainId) { - return { - ok: true, - evidence: { - target: ctx.target, - chain_supported: false, - chain: ctx.chain, - }, - provenance: { source: "etherscan.io", fetched_at: now }, - }; - } - - if (!process.env.ETHERSCAN_API_KEY) { - return { - ok: true, - evidence: { - target: ctx.target, - enabled: false, - note: "Sister-rug detector requires ETHERSCAN_API_KEY. Returning enabled:false; verdict treats as neutral.", - }, - provenance: { source: "etherscan.io", fetched_at: now }, - }; - } - - try { - const creation = await etherscanFetch({ - chainid: chainId, - module: "contract", - action: "getcontractcreation", - contractaddresses: ctx.target, - }); - - const result = Array.isArray(creation.result) ? creation.result[0] : null; - if (!result || !result.contractCreator) { - return { - ok: true, - evidence: { - target: ctx.target, - deployer_found: false, - note: "Could not resolve contract deployer.", - }, - provenance: { source: "etherscan.io", fetched_at: now }, - }; - } - - const deployer = String(result.contractCreator).toLowerCase(); - - const txList = await etherscanFetch({ - chainid: chainId, - module: "account", - action: "txlist", - address: deployer, - startblock: "0", - endblock: "99999999", - page: "1", - offset: "50", - sort: "desc", - }); - - const otherDeployments: string[] = []; - if (Array.isArray(txList.result)) { - for (const tx of txList.result) { - if (tx.to === "" || tx.to == null) { - const created = (tx.contractAddress ?? "").toLowerCase(); - if (created && created !== ctx.target.toLowerCase()) { - otherDeployments.push(created); - } - } - } - } - - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - deployer, - deployer_first_tx_hash: result.txHash ?? null, - prior_deployments_count: otherDeployments.length, - prior_deployments_sample: otherDeployments.slice(0, 10), - flagged_prior_deployments: [], - note: "v1 surfaces deployer history. Cross-reference with DefiLlama Hacks DB / ScamSniffer happens at composer level. v1.5 will add bytecode-similarity matching.", - }, - provenance: { source: "etherscan.io", fetched_at: now, endpoints: ["getcontractcreation", "txlist"] }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "etherscan.io", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/sourcify.ts b/apps/api/src/web3-assurance/evaluators/sourcify.ts deleted file mode 100644 index aad4cbf4..00000000 --- a/apps/api/src/web3-assurance/evaluators/sourcify.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Web3 Assurance — Sourcify decentralized contract verification. - * - * Free, no API key, no rate limit, all EVM chains. Verifies bytecode against - * compile metadata. Complements the contract-verify-check evaluator (which - * uses Etherscan-family) — when Sourcify says "full match" and Etherscan says - * "not verified", that's a meaningful disagreement the composer surfaces. - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator } from "../types.js"; - -const TIMEOUT_MS = 6000; -const FILES_CHECK_BASE = "https://sourcify.dev/server/check-by-addresses"; - -const CHAIN_TO_ID: Record = { - ethereum: "1", - base: "8453", - polygon: "137", - arbitrum: "42161", - optimism: "10", - bsc: "56", - avalanche: "43114", - "1": "1", - "8453": "8453", - "137": "137", - "42161": "42161", - "10": "10", - "56": "56", - "43114": "43114", -}; - -const evaluator: Evaluator = { - name: "sourcify-verification", - priority: "opportunistic", - appliesTo: (ctx) => - (ctx.targetType === "contract" || ctx.targetType === "token" || ctx.targetType === "protocol") && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target), - cacheTTLSeconds: 604800, - cacheKey: (ctx) => `sourcify:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const chainId = CHAIN_TO_ID[ctx.chain.toLowerCase()]; - if (!chainId) { - return { - ok: true, - evidence: { - target: ctx.target, - status: "chain_not_supported", - chain: ctx.chain, - }, - provenance: { source: "sourcify.dev", fetched_at: now }, - }; - } - - try { - const url = `${FILES_CHECK_BASE}?addresses=${encodeURIComponent(ctx.target)}&chainIds=${chainId}`; - const response = await fetch(url, { - headers: { "User-Agent": "Strale/1.0" }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - - if (!response.ok) { - throw new Error(`Sourcify HTTP ${response.status}`); - } - - const data = (await response.json()) as Array<{ - address: string; - status?: string; - chainIds?: Array<{ chainId: string; status: string }>; - }>; - - const entry = data.find( - (d) => d.address.toLowerCase() === ctx.target.toLowerCase(), - ); - - if (!entry) { - return { - ok: true, - evidence: { - target: ctx.target, - chain_id: chainId, - verified: false, - match_type: null, - note: "No Sourcify record found for this address.", - }, - provenance: { source: "sourcify.dev", fetched_at: now }, - }; - } - - const chainStatus = entry.chainIds?.find((c) => c.chainId === chainId)?.status ?? - entry.status ?? "false"; - const isVerified = chainStatus === "perfect" || chainStatus === "partial"; - - return { - ok: true, - evidence: { - target: ctx.target, - chain_id: chainId, - verified: isVerified, - match_type: chainStatus === "perfect" ? "full_match" : chainStatus === "partial" ? "partial_match" : "none", - }, - provenance: { source: "sourcify.dev", fetched_at: now }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "sourcify.dev", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/stablecoin-issuer.ts b/apps/api/src/web3-assurance/evaluators/stablecoin-issuer.ts deleted file mode 100644 index 43e1b954..00000000 --- a/apps/api/src/web3-assurance/evaluators/stablecoin-issuer.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Web3 Assurance — stablecoin issuer-jurisdiction evaluator. - * - * For wallets receiving stablecoin payment, classify each held stablecoin - * by issuer regulatory jurisdiction, freeze capability, reserve disclosure, - * and MiCA-authorisation status. Direct value for EU-CASP buyers ahead of - * MiCA Q3 2026 enforcement. - * - * v0.1: classifies the wallet's USDC/USDT/DAI/PYUSD/USDe/FDUSD/USDP holdings - * via the existing Etherscan tokentx API. Reads the wallet's recent token - * transfers, identifies stablecoin contract matches, classifies each. - * - * v0.2: extend to top-50 stablecoins + non-EVM chains (Solana SPL stables). - * - * Per DEC-20260428-A, Strale itself does not scrape; the issuer registry - * is hand-curated from public regulator filings + issuer disclosures. - */ - -import { registerEvaluator } from "./index.js"; -import { etherscanFetch } from "../../capabilities/lib/etherscan-client.js"; -import { lookupStablecoinIssuer } from "../data/stablecoin-issuers.js"; -import type { Evaluator } from "../types.js"; - -const CHAIN_TO_ID: Record = { - ethereum: "1", - base: "8453", - polygon: "137", - arbitrum: "42161", - optimism: "10", -}; - -interface EtherscanTokenTx { - contractAddress: string; - tokenSymbol: string; - to: string; - from: string; - timeStamp: string; - value: string; - tokenDecimal: string; -} - -const evaluator: Evaluator = { - name: "stablecoin-issuer", - priority: "opportunistic", - appliesTo: (ctx) => - ctx.targetType === "wallet" && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target) && - CHAIN_TO_ID[ctx.chain.toLowerCase()] !== undefined, - cacheTTLSeconds: 1800, - cacheKey: (ctx) => `stablecoin-issuer:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - - if (!process.env.ETHERSCAN_API_KEY) { - return { - ok: true, - evidence: { - target: ctx.target, - enabled: false, - note: "Stablecoin-issuer evaluator requires ETHERSCAN_API_KEY; verdict treats absence as neutral.", - }, - provenance: { source: "etherscan.io", fetched_at: now }, - }; - } - - try { - const data = await etherscanFetch({ - chainid: CHAIN_TO_ID[ctx.chain.toLowerCase()]!, - module: "account", - action: "tokentx", - address: ctx.target, - page: "1", - offset: "100", - sort: "desc", - }); - - const transfers = (Array.isArray(data.result) ? data.result : []) as EtherscanTokenTx[]; - - const seen: Map = new Map(); - const lower = ctx.target.toLowerCase(); - for (const tx of transfers) { - if ((tx.to ?? "").toLowerCase() !== lower) continue; - const key = (tx.contractAddress ?? "").toLowerCase(); - if (!key) continue; - const existing = seen.get(key) ?? { received_count: 0, last_received_at: null }; - existing.received_count += 1; - const ts = parseInt(tx.timeStamp, 10); - const iso = new Date(ts * 1000).toISOString(); - if (!existing.last_received_at || iso > existing.last_received_at) { - existing.last_received_at = iso; - } - seen.set(key, existing); - } - - const stablecoinHoldings: Array> = []; - const jurisdictionsSeen = new Set(); - const nonMicaSymbols: string[] = []; - let nonFreezableCount = 0; - - for (const [contract, stats] of seen.entries()) { - const entry = lookupStablecoinIssuer(contract, ctx.chain); - if (!entry) continue; - - stablecoinHoldings.push({ - contract_address: contract, - symbol: entry.symbol, - issuer: entry.issuer, - jurisdiction: entry.jurisdiction, - freeze_capability: entry.freeze_capability, - reserve_disclosure: entry.reserve_disclosure, - mica_compliant: entry.mica_compliant, - received_count: stats.received_count, - last_received_at: stats.last_received_at, - notes: entry.notes, - }); - jurisdictionsSeen.add(entry.jurisdiction); - if (!entry.mica_compliant) nonMicaSymbols.push(entry.symbol); - if (entry.freeze_capability === "non_freezable") nonFreezableCount += 1; - } - - let issuerRiskLevel: "high" | "medium" | "low" | "unknown"; - if (stablecoinHoldings.length === 0) { - issuerRiskLevel = "unknown"; - } else if (nonMicaSymbols.length === stablecoinHoldings.length) { - issuerRiskLevel = "high"; - } else if (nonMicaSymbols.length > 0) { - issuerRiskLevel = "medium"; - } else { - issuerRiskLevel = "low"; - } - - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - stablecoin_holdings_classified: stablecoinHoldings, - stablecoin_holdings_count: stablecoinHoldings.length, - jurisdictions_held: Array.from(jurisdictionsSeen), - non_mica_compliant_symbols: nonMicaSymbols, - non_freezable_count: nonFreezableCount, - issuer_risk_level: issuerRiskLevel, - mica_q3_2026_relevant: nonMicaSymbols.length > 0, - }, - provenance: { source: "etherscan.io", fetched_at: now }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "etherscan.io", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/tenderly-simulation.ts b/apps/api/src/web3-assurance/evaluators/tenderly-simulation.ts deleted file mode 100644 index 8f1e4617..00000000 --- a/apps/api/src/web3-assurance/evaluators/tenderly-simulation.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Web3 Assurance — Tenderly pre-trade simulation. - * - * Simulates the user's specific transaction (not just static contract - * analysis). Returns: predicted output, balance changes, slippage, gas, - * and revert reason if simulation fails. - * - * Free signup tier sufficient for v1 alpha volume; upgrade if quota hit. - * - * Only runs when ctx.action is provided (we have a transaction to simulate). - * Otherwise returns "skipped" and composer treats as not-applicable. - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator } from "../types.js"; - -const TIMEOUT_MS = 10000; - -const NETWORK_ID_BY_CHAIN: Record = { - ethereum: "1", - base: "8453", - polygon: "137", - arbitrum: "42161", - optimism: "10", - bsc: "56", -}; - -const evaluator: Evaluator = { - name: "pre-trade-simulation", - priority: "opportunistic", - appliesTo: (ctx) => - ctx.action !== undefined && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target) && - NETWORK_ID_BY_CHAIN[ctx.chain.toLowerCase()] !== undefined, - cacheTTLSeconds: 0, - cacheKey: (ctx) => `tenderly:${ctx.chain}:${ctx.target.toLowerCase()}:${ctx.action}:${ctx.amountUsd ?? "any"}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const accountSlug = process.env.TENDERLY_ACCOUNT; - const projectSlug = process.env.TENDERLY_PROJECT; - const accessKey = process.env.TENDERLY_ACCESS_KEY; - - if (!accountSlug || !projectSlug || !accessKey) { - return { - ok: true, - evidence: { - target: ctx.target, - enabled: false, - note: "Tenderly simulation disabled — TENDERLY_ACCOUNT / TENDERLY_PROJECT / TENDERLY_ACCESS_KEY not configured. Verdict treats as neutral.", - }, - provenance: { source: "tenderly.co", fetched_at: now }, - }; - } - - const networkId = NETWORK_ID_BY_CHAIN[ctx.chain.toLowerCase()]!; - const url = `https://api.tenderly.co/api/v1/account/${accountSlug}/project/${projectSlug}/simulate`; - - try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Access-Key": accessKey, - "User-Agent": "Strale/1.0", - }, - body: JSON.stringify({ - network_id: networkId, - from: "0x0000000000000000000000000000000000000001", - to: ctx.target, - input: "0x", - gas: 8000000, - gas_price: "0", - value: ctx.amountUsd ? "0" : "0", - save: false, - save_if_fails: false, - simulation_type: "quick", - }), - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - - if (!response.ok) throw new Error(`Tenderly HTTP ${response.status}`); - - const data = (await response.json()) as { - transaction?: { - status?: boolean; - gas_used?: number; - error_message?: string; - error_info?: unknown; - }; - }; - - const tx = data.transaction; - const success = tx?.status === true; - - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - action: ctx.action, - simulation_success: success, - gas_used: tx?.gas_used ?? null, - error_message: tx?.error_message ?? null, - note: success - ? "Simulated transaction succeeded. v1 simulation is a basic call to the target; v1.5 will simulate the full agent-intent payload." - : "Simulated transaction reverted. Possible honeypot, slippage trap, or guarded contract. Investigate before proceeding.", - }, - provenance: { source: "tenderly.co", fetched_at: now }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "tenderly.co", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/wallet-velocity.ts b/apps/api/src/web3-assurance/evaluators/wallet-velocity.ts deleted file mode 100644 index a129d41d..00000000 --- a/apps/api/src/web3-assurance/evaluators/wallet-velocity.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Web3 Assurance — wallet velocity / behavioral anomaly evaluator. - * - * Strategic memo §2 identified velocity / funding-pattern / sweep behaviour - * as free on-chain signals nobody synthesizes. v0.1 surfaces three signals - * derivable from the wallet-transactions evaluator output: - * - * - tx_velocity: median time between consecutive transactions - * - sweep_pattern: ratio of inbound -> immediate outbound (within 60s) - * - dormancy: was the wallet dormant for >90d before recent activity? - * - * Cheap (no extra API calls — reads existing evaluator output via the - * composer's evidence cache when present, or falls back to gracefully - * declaring 'evidence_not_available'). Does not predict; classifies - * verifiable behavioral facts. - * - * Not a model. Not a fraud-prediction score. Just shape-of-activity - * signals an agent can act on. - */ - -import { registerEvaluator } from "./index.js"; -import { etherscanFetch } from "../../capabilities/lib/etherscan-client.js"; -import type { Evaluator } from "../types.js"; - -const CHAIN_TO_ID: Record = { - ethereum: "1", - base: "8453", - polygon: "137", - arbitrum: "42161", - optimism: "10", - bsc: "56", -}; - -const SWEEP_WINDOW_SECONDS = 60; -const DORMANCY_THRESHOLD_DAYS = 90; -const ACTIVE_RECENT_DAYS = 7; - -interface EtherscanTx { - hash: string; - from: string; - to: string; - value: string; - timeStamp: string; -} - -const evaluator: Evaluator = { - name: "wallet-velocity", - priority: "opportunistic", - appliesTo: (ctx) => - ctx.targetType === "wallet" && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target) && - CHAIN_TO_ID[ctx.chain.toLowerCase()] !== undefined, - cacheTTLSeconds: 1800, - cacheKey: (ctx) => `velocity:${ctx.chain}:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - const chainId = CHAIN_TO_ID[ctx.chain.toLowerCase()]!; - - if (!process.env.ETHERSCAN_API_KEY) { - return { - ok: true, - evidence: { - target: ctx.target, - enabled: false, - note: "Wallet-velocity evaluator requires ETHERSCAN_API_KEY; verdict treats absence as neutral.", - }, - provenance: { source: "etherscan.io", fetched_at: now }, - }; - } - - try { - const data = await etherscanFetch({ - chainid: chainId, - module: "account", - action: "txlist", - address: ctx.target, - startblock: "0", - endblock: "99999999", - page: "1", - offset: "100", - sort: "desc", - }); - - const txs = (Array.isArray(data.result) ? data.result : []) as EtherscanTx[]; - if (txs.length === 0) { - return { - ok: true, - evidence: { - target: ctx.target, - no_activity: true, - note: "Wallet has no transactions; velocity signals not applicable.", - }, - provenance: { source: "etherscan.io", fetched_at: now }, - }; - } - - const sortedAsc = [...txs].sort( - (a, b) => parseInt(a.timeStamp, 10) - parseInt(b.timeStamp, 10), - ); - - const intervals: number[] = []; - for (let i = 1; i < sortedAsc.length; i++) { - intervals.push( - parseInt(sortedAsc[i].timeStamp, 10) - - parseInt(sortedAsc[i - 1].timeStamp, 10), - ); - } - intervals.sort((a, b) => a - b); - const medianIntervalSec = - intervals.length === 0 - ? null - : intervals[Math.floor(intervals.length / 2)]; - - const lower = ctx.target.toLowerCase(); - let inboundCount = 0; - let sweepCount = 0; - for (let i = 0; i < sortedAsc.length; i++) { - if ((sortedAsc[i].to ?? "").toLowerCase() !== lower) continue; - inboundCount += 1; - const inboundTs = parseInt(sortedAsc[i].timeStamp, 10); - for (let j = i + 1; j < sortedAsc.length; j++) { - const next = sortedAsc[j]; - const nextTs = parseInt(next.timeStamp, 10); - if (nextTs - inboundTs > SWEEP_WINDOW_SECONDS) break; - if ((next.from ?? "").toLowerCase() === lower) { - sweepCount += 1; - break; - } - } - } - const sweepRatio = inboundCount > 0 ? sweepCount / inboundCount : 0; - - const lastTs = parseInt(sortedAsc[sortedAsc.length - 1].timeStamp, 10); - const firstTs = parseInt(sortedAsc[0].timeStamp, 10); - const ageDays = (Date.now() / 1000 - firstTs) / 86400; - const sinceLastDays = (Date.now() / 1000 - lastTs) / 86400; - - let dormantThenActive = false; - let dormancyGapDays = 0; - if (intervals.length > 0) { - const maxIntervalSec = Math.max(...intervals); - dormancyGapDays = maxIntervalSec / 86400; - dormantThenActive = - dormancyGapDays > DORMANCY_THRESHOLD_DAYS && - sinceLastDays < ACTIVE_RECENT_DAYS; - } - - const isHighFrequency = - medianIntervalSec !== null && medianIntervalSec < 30 && sortedAsc.length >= 5; - const isSweepHeavy = sweepRatio > 0.5 && inboundCount >= 3; - - const flags: string[] = []; - if (isHighFrequency) flags.push("velocity_bot_pattern"); - if (isSweepHeavy) flags.push("sweep_pattern"); - if (dormantThenActive) flags.push("dormant_then_active"); - - return { - ok: true, - evidence: { - target: ctx.target, - chain: ctx.chain, - tx_sample_size: sortedAsc.length, - age_days: Math.round(ageDays), - since_last_tx_days: Math.round(sinceLastDays), - median_interval_seconds: medianIntervalSec, - inbound_count: inboundCount, - sweep_count: sweepCount, - sweep_ratio: Math.round(sweepRatio * 100) / 100, - dormancy_gap_days: Math.round(dormancyGapDays), - is_high_frequency: isHighFrequency, - is_sweep_heavy: isSweepHeavy, - dormant_then_active: dormantThenActive, - behavioral_flags: flags, - }, - provenance: { source: "etherscan.io", fetched_at: now }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "etherscan.io", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/evaluators/web3-antivirus.ts b/apps/api/src/web3-assurance/evaluators/web3-antivirus.ts deleted file mode 100644 index 53a2fab1..00000000 --- a/apps/api/src/web3-assurance/evaluators/web3-antivirus.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Web3 Assurance — Web3 Antivirus free API supplement. - * - * Web3 Antivirus provides a free public API for wallet risk + poisoning - * attack detection. Used as a SUPPLEMENT to wallet-history-risk (which - * uses GoPlus). When the two disagree, the composer surfaces the - * disagreement — that's a Strale-only output other competitors don't have. - */ - -import { registerEvaluator } from "./index.js"; -import type { Evaluator } from "../types.js"; - -const API_BASE = "https://api.web3antivirus.io/v1"; -const TIMEOUT_MS = 6000; - -const evaluator: Evaluator = { - name: "web3-antivirus-risk", - priority: "opportunistic", - appliesTo: (ctx) => - ctx.targetType === "wallet" && - /^0x[a-fA-F0-9]{40}$/.test(ctx.target), - cacheTTLSeconds: 1800, - cacheKey: (ctx) => `web3av:${ctx.target.toLowerCase()}`, - run: async (ctx) => { - const now = new Date().toISOString(); - try { - const url = `${API_BASE}/wallet/${encodeURIComponent(ctx.target)}/risk`; - const response = await fetch(url, { - headers: { "User-Agent": "Strale/1.0", Accept: "application/json" }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - - if (response.status === 404) { - return { - ok: true, - evidence: { - target: ctx.target, - found: false, - note: "Web3 Antivirus has no record for this address.", - }, - provenance: { source: "web3antivirus.io", fetched_at: now }, - }; - } - - if (!response.ok) throw new Error(`Web3 Antivirus HTTP ${response.status}`); - - const data = (await response.json()) as Record; - - return { - ok: true, - evidence: { - target: ctx.target, - found: true, - raw: data, - }, - provenance: { source: "web3antivirus.io", fetched_at: now }, - }; - } catch (err) { - return { - ok: false, - evidence: null, - provenance: { source: "web3antivirus.io", fetched_at: now }, - error: err instanceof Error ? err.message : String(err), - }; - } - }, -}; - -registerEvaluator(evaluator); diff --git a/apps/api/src/web3-assurance/explanation.ts b/apps/api/src/web3-assurance/explanation.ts deleted file mode 100644 index 28f59568..00000000 --- a/apps/api/src/web3-assurance/explanation.ts +++ /dev/null @@ -1,436 +0,0 @@ -/** - * Web3 Assurance — explanation chain builder. - * - * Takes the verdict's reason_codes and walks each one back to: - * - The source evaluator that produced it - * - The specific evidence fields that triggered it - * - A "why it matters" string - * - * Output is a structured causal chain that downstream consumers (workflow - * gates, human reviewers, regulator-shareable audit artifacts) can walk to - * understand WHY a verdict fired, not just THAT it fired. - * - * v0.1 covers the ~40 reason codes Strale ships today. Codes not in the - * EXPLAINERS map fall back to a generic "see methodology" link so the - * chain is never silently incomplete. - */ - -import type { ComposeResult } from "./composer.js"; -import type { VerdictResult } from "./verdict.js"; -import type { ExplanationLink } from "./types.js"; - -interface ExplainerSpec { - source_evaluator: string; - evidence_keys: string[]; - severity: "critical" | "review"; - why: (evidence: Record) => string; -} - -const CRITICAL = "critical" as const; -const REVIEW = "review" as const; - -function pick( - evidence: Record | null | undefined, - keys: string[], -): Record { - if (!evidence) return {}; - const out: Record = {}; - for (const key of keys) { - if (key in evidence) out[key] = evidence[key]; - } - return out; -} - -const EXPLAINERS: Record = { - SANCTIONS_MATCH: { - source_evaluator: "sanctions", - evidence_keys: ["is_match", "matched_lists", "match_classification"], - severity: CRITICAL, - why: () => - "Counterparty matched against an OFAC SDN, UN, EU, OFSI, or Swiss SECO sanctions list. Transacting with sanctioned entities is prohibited under the caller's regulatory regime.", - }, - WALLET_MALICIOUS: { - source_evaluator: "wallet-history-risk", - evidence_keys: ["is_malicious", "risk_labels", "risk_level"], - severity: CRITICAL, - why: () => - "GoPlus address-security flagged this wallet as malicious based on transaction-pattern analysis across known-bad clusters.", - }, - WALLET_PHISHING: { - source_evaluator: "wallet-history-risk", - evidence_keys: ["risk_labels"], - severity: CRITICAL, - why: () => - "Wallet linked to phishing activity. Sending funds to phishing wallets is unrecoverable; recipient typically sweeps within minutes.", - }, - WALLET_MONEY_LAUNDERING: { - source_evaluator: "wallet-history-risk", - evidence_keys: ["risk_labels"], - severity: CRITICAL, - why: () => - "Wallet associated with money-laundering patterns. Receiving funds via this wallet may trigger AML reporting obligations on the caller side.", - }, - WALLET_FINANCIAL_CRIME: { - source_evaluator: "wallet-history-risk", - evidence_keys: ["risk_labels"], - severity: CRITICAL, - why: () => "Wallet associated with documented financial-crime activity.", - }, - WALLET_CYBERCRIME: { - source_evaluator: "wallet-history-risk", - evidence_keys: ["risk_labels"], - severity: CRITICAL, - why: () => "Wallet associated with cybercrime activity.", - }, - WALLET_DARKWEB: { - source_evaluator: "wallet-history-risk", - evidence_keys: ["risk_labels"], - severity: CRITICAL, - why: () => "Wallet associated with darkweb-marketplace transactions.", - }, - WALLET_BLACKLIST: { - source_evaluator: "wallet-history-risk", - evidence_keys: ["risk_labels"], - severity: REVIEW, - why: () => "Wallet present on a third-party blacklist. Investigate origin of the flag before transacting.", - }, - WALLET_FAKE_KYC: { - source_evaluator: "wallet-history-risk", - evidence_keys: ["risk_labels"], - severity: REVIEW, - why: () => "Wallet associated with fake-KYC patterns commonly used to bypass exchange controls.", - }, - WALLET_MALICIOUS_MINING: { - source_evaluator: "wallet-history-risk", - evidence_keys: ["risk_labels"], - severity: REVIEW, - why: () => "Wallet associated with malicious-mining (cryptojacking) activity.", - }, - - TOKEN_HONEYPOT: { - source_evaluator: "token-safety", - evidence_keys: ["is_honeypot", "sell_tax", "buy_tax"], - severity: CRITICAL, - why: () => - "Token contract is a honeypot — buyers can purchase but cannot sell. Funds entering this contract become unrecoverable.", - }, - TOKEN_SELL_TAX_EXTREME: { - source_evaluator: "token-safety", - evidence_keys: ["sell_tax", "buy_tax"], - severity: CRITICAL, - why: (e) => - `Sell tax of ${e.sell_tax ?? "unknown"} (>50%) confiscates the majority of any sell-side proceeds. Effectively unsellable.`, - }, - TOKEN_SELL_TAX_HIGH: { - source_evaluator: "token-safety", - evidence_keys: ["sell_tax", "buy_tax"], - severity: REVIEW, - why: (e) => - `Sell tax of ${e.sell_tax ?? "unknown"} (10-50%) materially reduces realisable value on exit. Verify tax is intentional and not a soft-rug pattern.`, - }, - TOKEN_HIDDEN_OWNER: { - source_evaluator: "token-safety", - evidence_keys: ["hidden_owner", "owner_address"], - severity: REVIEW, - why: () => - "Token has a hidden owner who can modify contract behaviour (mint, blacklist, fee changes). Owner can rug at any time.", - }, - TOKEN_RECLAIMABLE_OWNERSHIP: { - source_evaluator: "token-safety", - evidence_keys: ["can_take_back_ownership"], - severity: REVIEW, - why: () => - "Token allows the original deployer to reclaim ownership even after a transferOwnership call. Renounced ownership claims can't be trusted.", - }, - TOKEN_BLACKLISTED: { - source_evaluator: "token-safety", - evidence_keys: ["is_blacklisted"], - severity: REVIEW, - why: () => "Token is on a security blacklist. Investigate provenance before transacting.", - }, - TOKEN_CLOSED_SOURCE: { - source_evaluator: "token-safety", - evidence_keys: ["is_open_source"], - severity: REVIEW, - why: () => - "Token contract source is not verified or open. Behaviour cannot be audited; rug-vector exposure is opaque.", - }, - - CONTRACT_UNVERIFIED: { - source_evaluator: "contract-verification", - evidence_keys: ["is_verified", "contract_name"], - severity: REVIEW, - why: () => - "Contract source code is not verified on Etherscan or Sourcify. Bytecode-only contracts cannot be audited; behaviour is opaque.", - }, - CONTRACT_PROXY_NO_IMPL: { - source_evaluator: "contract-verification", - evidence_keys: ["is_proxy", "implementation_address"], - severity: REVIEW, - why: () => - "Contract is a proxy with no resolvable implementation address. Implementation can be swapped silently; current logic is unknowable.", - }, - - APPROVALS_RISKY: { - source_evaluator: "approval-inventory", - evidence_keys: ["risky_approvals", "total_approvals"], - severity: REVIEW, - why: (e) => - `Wallet has ${e.risky_approvals ?? "≥1"} risky token approvals (unlimited or to suspicious spenders). Drainer attacks exploit these even after the original approval flow ended.`, - }, - - PROTOCOL_RECENT_EXPLOIT_30D: { - source_evaluator: "protocol-risk", - evidence_keys: ["incidents", "protocol_name"], - severity: CRITICAL, - why: (e) => { - const incidents = e.incidents as Record | undefined; - const days = incidents?.days_since_last_incident; - return `Protocol exploited ${days ?? "<30"} days ago. Acute exposure window: residual on-chain effects of the exploit may still be propagating.`; - }, - }, - PROTOCOL_RECENT_EXPLOIT_90D: { - source_evaluator: "protocol-risk", - evidence_keys: ["incidents", "protocol_name"], - severity: REVIEW, - why: (e) => { - const incidents = e.incidents as Record | undefined; - const days = incidents?.days_since_last_incident; - return `Protocol exploited ${days ?? "<90"} days ago. Confidence in remediation is unverified at this distance.`; - }, - }, - PROTOCOL_REPEAT_EXPLOITED: { - source_evaluator: "protocol-risk", - evidence_keys: ["incidents"], - severity: REVIEW, - why: (e) => { - const incidents = e.incidents as Record | undefined; - return `Protocol has ${incidents?.count ?? "≥3"} documented incidents in DefiLlama Hacks DB. Pattern suggests structural rather than incidental security weakness.`; - }, - }, - PROTOCOL_NOT_INDEXED: { - source_evaluator: "protocol-risk", - evidence_keys: ["found", "note"], - severity: REVIEW, - why: () => - "Protocol target not found in DefiLlama. Treat as unknown rather than safe — DefiLlama indexes most protocols above $1M TVL, so absence implies either very small TVL, very new, or untracked.", - }, - - MIXER_SANCTIONED: { - source_evaluator: "mixer-graded", - evidence_keys: ["service", "category", "regulatory_note"], - severity: CRITICAL, - why: (e) => - `Address is the ${e.service ?? "a"} mixer, currently sanctioned. Receiving funds from or sending funds to this address may trigger sanctions-violation exposure.`, - }, - MIXER_HIGH_RISK: { - source_evaluator: "mixer-graded", - evidence_keys: ["service", "category", "regulatory_note"], - severity: CRITICAL, - why: (e) => - `Address is ${e.service ?? "a known high-risk mixer"} with documented criminal-actor use. Direct interaction creates AML and sanctions-screening exposure.`, - }, - MIXER_DELISTED_ELEVATED: { - source_evaluator: "mixer-graded", - evidence_keys: ["service", "category", "risk_weight", "jurisdiction_interpretation"], - severity: REVIEW, - why: (e) => - `Address is the ${e.service ?? "a"} mixer (previously sanctioned, delisted by OFAC March 2025). Treasury 2026 guidance acknowledges legitimate privacy use, but elevated AML/KYC scrutiny still applies.`, - }, - MIXER_DELISTED: { - source_evaluator: "mixer-graded", - evidence_keys: ["service", "category", "risk_weight"], - severity: REVIEW, - why: (e) => `Address is a delisted mixer (${e.service ?? "unknown"}). Graded-risk handling per Treasury 2026 mixer guidance.`, - }, - MIXER_UNCLASSIFIED: { - source_evaluator: "mixer-graded", - evidence_keys: ["service", "category"], - severity: REVIEW, - why: () => "Address is in the mixer corpus but unclassified. Investigate before transacting.", - }, - - SCAM_CLUSTER_MATCH: { - source_evaluator: "scam-cluster", - evidence_keys: ["is_scam_cluster", "list_fetched_at"], - severity: CRITICAL, - why: () => - "Address matches the ScamSniffer phishing-wallet cluster. Phishing wallets typically present low-malicious-signal until they sweep, so this evidence overrides cleaner upstream wallet-risk reads.", - }, - - BRIDGE_SINGLE_POINT_OF_FAILURE: { - source_evaluator: "bridge-config-risk", - evidence_keys: [ - "protocol_name", - "dvn_config", - "spof_modes", - "is_single_point_of_failure", - ], - severity: CRITICAL, - why: (e) => { - const cfg = e.dvn_config as Record | undefined; - const required = cfg?.required_dvn_count ?? "?"; - const optional = cfg?.optional_dvn_count ?? "?"; - return `Bridge verification setup has single-point-of-failure: required DVN count = ${required}, optional DVN count = ${optional}. KelpDAO had this exact 1-of-1 DVN configuration before its $292M April 2026 exploit.`; - }, - }, - BRIDGE_CONFIG_CRITICAL: { - source_evaluator: "bridge-config-risk", - evidence_keys: ["risk_level", "spof_modes", "protocol_name"], - severity: CRITICAL, - why: () => - "Bridge configuration classified as critical-risk by Strale's curated index or live on-chain getConfig read.", - }, - BRIDGE_CONFIG_HIGH_RISK: { - source_evaluator: "bridge-config-risk", - evidence_keys: ["risk_level", "dvn_config"], - severity: REVIEW, - why: () => "Bridge configuration classified as high-risk: redundancy below recommended thresholds.", - }, - BRIDGE_SINGLE_REQUIRED_DVN: { - source_evaluator: "bridge-config-risk", - evidence_keys: ["dvn_config"], - severity: REVIEW, - why: () => - "Bridge requires only 1 DVN to attest cross-chain messages. No redundancy: a single DVN compromise breaks message verification.", - }, - BRIDGE_RECENT_INCIDENT_365D: { - source_evaluator: "bridge-config-risk", - evidence_keys: ["last_incident", "historical_incidents_recent_year"], - severity: CRITICAL, - why: (e) => { - const last = e.last_incident as Record | undefined; - const date = last?.date; - const amount = last?.amount_usd; - return `Bridge had a documented incident on ${date ?? "an undisclosed date"}${ - amount ? ` (~$${amount} loss)` : "" - } within the last 365 days.`; - }, - }, - - EXPOSURE_DEPENDENCY_RECENT_EXPLOIT_90D: { - source_evaluator: "cross-protocol-exposure", - evidence_keys: ["last_related_hack", "exposure_risk_level", "parent_protocol", "forked_from"], - severity: CRITICAL, - why: (e) => { - const last = e.last_related_hack as Record | undefined; - const name = last?.name; - return `A protocol this target depends on (${ - name ?? "parent / fork / oracle" - }) was exploited within the last 90 days. Cascading exposure risk: target's funds may sit downstream of the exploit's residual effects.`; - }, - }, - EXPOSURE_DEPENDENCY_EXPLOITED_YEAR: { - source_evaluator: "cross-protocol-exposure", - evidence_keys: ["last_related_hack", "parent_protocol"], - severity: REVIEW, - why: () => "A dependency was exploited within the last 365 days. Indirect exposure to remediation effectiveness.", - }, - EXPOSURE_UNKNOWN_DEPENDENCIES: { - source_evaluator: "cross-protocol-exposure", - evidence_keys: ["unknown_oracles", "forked_from"], - severity: REVIEW, - why: () => "Target has dependencies (parent / fork / oracle) outside the reputable set.", - }, - EXPOSURE_DEPENDENCY_HAS_HISTORY: { - source_evaluator: "cross-protocol-exposure", - evidence_keys: ["related_hacks_count", "last_related_hack"], - severity: REVIEW, - why: () => "A dependency in this target's chain has a documented hack/incident history.", - }, - EXPOSURE_UNKNOWN_ORACLE: { - source_evaluator: "cross-protocol-exposure", - evidence_keys: ["unknown_oracles", "reputable_oracles"], - severity: REVIEW, - why: (e) => { - const unknown = e.unknown_oracles; - return `Target depends on at least one oracle outside the reputable set${ - Array.isArray(unknown) && unknown.length > 0 ? ` (${unknown.join(", ")})` : "" - }. Reputable oracles: Chainlink, Pyth, RedStone, API3, UMA, Tellor.`; - }, - }, - - VELOCITY_BOT_PATTERN: { - source_evaluator: "wallet-velocity", - evidence_keys: ["median_interval_seconds", "tx_sample_size"], - severity: REVIEW, - why: (e) => - `Wallet's median inter-transaction interval is ${e.median_interval_seconds ?? "<30"}s with ${ - e.tx_sample_size ?? "≥5" - } sample transactions. Indicates automated / bot operation.`, - }, - VELOCITY_SWEEP_PATTERN: { - source_evaluator: "wallet-velocity", - evidence_keys: ["sweep_ratio", "inbound_count", "sweep_count"], - severity: REVIEW, - why: (e) => - `${Math.round(((e.sweep_ratio as number) ?? 0) * 100)}% of inbound transfers are followed by an outbound transfer within 60s. Sweep-bot behaviour; common in drainer flows.`, - }, - VELOCITY_DORMANT_THEN_ACTIVE: { - source_evaluator: "wallet-velocity", - evidence_keys: ["dormancy_gap_days", "since_last_tx_days"], - severity: REVIEW, - why: (e) => - `Wallet was dormant for ${e.dormancy_gap_days ?? ">90"} days then became active in the last ${e.since_last_tx_days ?? "<7"} days. Common pattern for compromised or sleeper accounts.`, - }, - - STABLECOIN_NON_MICA_ONLY: { - source_evaluator: "stablecoin-issuer", - evidence_keys: ["non_mica_compliant_symbols", "stablecoin_holdings_count"], - severity: REVIEW, - why: (e) => - `All stablecoin holdings (${ - Array.isArray(e.non_mica_compliant_symbols) - ? (e.non_mica_compliant_symbols as string[]).join(", ") - : "?" - }) are issued by non-MiCA-authorised issuers. EU CASPs are restricting handling of these post-July 2026.`, - }, - STABLECOIN_NON_MICA_PARTIAL: { - source_evaluator: "stablecoin-issuer", - evidence_keys: ["non_mica_compliant_symbols"], - severity: REVIEW, - why: () => - "Some stablecoin holdings are non-MiCA-authorised. Partial regulatory exposure for EU recipients.", - }, - STABLECOIN_MICA_REVIEW_RECOMMENDED: { - source_evaluator: "stablecoin-issuer", - evidence_keys: ["mica_q3_2026_relevant", "stablecoin_holdings_classified"], - severity: REVIEW, - why: () => - "At least one stablecoin held requires MiCA-jurisdiction review for EU receipt. Verify recipient's CASP status before transacting.", - }, - - BYTECODE_RUG_MATCH: { - source_evaluator: "bytecode-similarity", - evidence_keys: ["match", "bytecode_sha256"], - severity: CRITICAL, - why: (e) => { - const match = e.match as Record | undefined; - const pattern = match?.pattern_name; - const classification = match?.classification; - return `Deployed bytecode (after metadata normalization) exact-matches a known-rug pattern in Strale's curated rug-bytecode index${ - pattern ? `: ${pattern}` : "" - }${classification ? ` (${classification})` : ""}.`; - }, - }, -}; - -export function buildExplanationChain( - compose: ComposeResult, - verdict: VerdictResult, -): ExplanationLink[] { - const chain: ExplanationLink[] = []; - for (const code of verdict.reason_codes) { - const explainer = EXPLAINERS[code]; - if (!explainer) continue; - const evidence = compose.evidence[explainer.source_evaluator] ?? null; - chain.push({ - reason_code: code, - severity: explainer.severity, - source_evaluator: explainer.source_evaluator, - evidence_excerpt: pick(evidence, explainer.evidence_keys), - why: explainer.why(evidence ?? {}), - }); - } - return chain; -} diff --git a/apps/api/src/web3-assurance/index.ts b/apps/api/src/web3-assurance/index.ts deleted file mode 100644 index 99b08c4d..00000000 --- a/apps/api/src/web3-assurance/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Web3 Assurance — public module surface. - * - * Importing this module auto-registers all evaluators. The route handler - * and tests both import from here so registration is a side effect of - * loading any consumer. - */ - -import "./evaluators/existing-cap.js"; -import "./evaluators/defillama-protocol.js"; -import "./evaluators/sourcify.js"; -import "./evaluators/mixer-graded.js"; -import "./evaluators/scamsniffer.js"; -import "./evaluators/eas-attestations.js"; -import "./evaluators/erc-8004-reputation.js"; -import "./evaluators/sister-rug.js"; -import "./evaluators/web3-antivirus.js"; -import "./evaluators/tenderly-simulation.js"; -import "./evaluators/defillama-bridges.js"; -import "./evaluators/rekt-database.js"; -import "./evaluators/audit-firms.js"; -import "./evaluators/bridge-config-risk.js"; -import "./evaluators/cross-protocol-exposure.js"; -import "./evaluators/wallet-velocity.js"; -import "./evaluators/stablecoin-issuer.js"; -import "./evaluators/bytecode-similarity.js"; - -export { compose, inferTargetType, inferChain } from "./composer.js"; -export { computeVerdict } from "./verdict.js"; -export { getEvaluators, getEvaluator } from "./evaluators/index.js"; -export type { - Web3AssuranceRequest, - Web3AssuranceResponse, - EvaluatorContext, - EvaluatorResult, - Evaluator, - EvaluatorPriority, - TargetType, - Action, - Verdict, - Mode, - SlaSpec, -} from "./types.js"; -export type { VerdictResult } from "./verdict.js"; diff --git a/apps/api/src/web3-assurance/lib/layerzero-config.ts b/apps/api/src/web3-assurance/lib/layerzero-config.ts deleted file mode 100644 index c17a9db8..00000000 --- a/apps/api/src/web3-assurance/lib/layerzero-config.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * LayerZero V2 endpoint.getConfig live reader (using viem). - * - * Calls EndpointV2.getConfig(address _oapp, address _lib, uint32 _eid, - * uint32 _configType) and decodes the returned UlnConfig struct via viem - * ABI tools. v0.2 of bridge-config-risk uses this as a fallback when the - * curated seed misses. - * - * UlnConfig struct (from LayerZero V2): - * uint64 confirmations - * uint8 requiredDVNCount - * uint8 optionalDVNCount - * uint8 optionalDVNThreshold - * address[] requiredDVNs - * address[] optionalDVNs - * - * Encoded as ABI struct ("(uint64,uint8,uint8,uint8,address[],address[])"). - */ - -import { - createPublicClient, - http, - encodeFunctionData, - decodeAbiParameters, - parseAbiParameters, - type AbiParameter, -} from "viem"; -import { mainnet, base, arbitrum, optimism, polygon, bsc, avalanche } from "viem/chains"; -import { getEthRpcEndpoints } from "../../lib/eth-rpc-endpoints.js"; - -const ENDPOINT_V2_BY_CHAIN: Record = { - ethereum: "0x1a44076050125825900e736c501f859c50fE728c", - base: "0x1a44076050125825900e736c501f859c50fE728c", - arbitrum: "0x1a44076050125825900e736c501f859c50fE728c", - optimism: "0x1a44076050125825900e736c501f859c50fE728c", - polygon: "0x1a44076050125825900e736c501f859c50fE728c", - bsc: "0x1a44076050125825900e736c501f859c50fE728c", - avalanche: "0x1a44076050125825900e736c501f859c50fE728c", -}; - -const SEND_ULN302_BY_CHAIN: Record = { - ethereum: "0xbB2Ea70C9E858123480642Cf96acbcCE1372dCe1", - base: "0xB5320B0B3a13cC860893E2Bd79FCd7e13484Dda2", - arbitrum: "0x975bcD720be66659e3EB3C0e4F1866a3020E493A", - optimism: "0x1322871e4ab09Bc7f5717189434f97bBD9546e95", - polygon: "0x6c26c61a97006888ea9E4FA36584c7df57Cd9dA3", - bsc: "0x9F8C645f2D0b2159767Bd6E0839DE4BE49e823DE", - avalanche: "0x197D1333DEA5Fe0D6600E9b396c7f1B1cFCc558a", -}; - -const DEFAULT_DST_EID_BY_SRC: Record = { - ethereum: 30184, - base: 30101, - arbitrum: 30101, - optimism: 30101, - polygon: 30101, - bsc: 30101, - avalanche: 30101, -}; - -const VIEM_CHAIN_BY_NAME: Record = { - ethereum: mainnet, - base, - arbitrum, - optimism, - polygon, - bsc, - avalanche, -}; - -const ULN_CONFIG_TYPE = 2; - -const GET_CONFIG_ABI = [ - { - name: "getConfig", - type: "function", - stateMutability: "view", - inputs: [ - { name: "_oapp", type: "address" }, - { name: "_lib", type: "address" }, - { name: "_eid", type: "uint32" }, - { name: "_configType", type: "uint32" }, - ], - outputs: [{ name: "config", type: "bytes" }], - }, -] as const; - -const ULN_CONFIG_PARAMS: readonly AbiParameter[] = parseAbiParameters( - "(uint64 confirmations, uint8 requiredDVNCount, uint8 optionalDVNCount, uint8 optionalDVNThreshold, address[] requiredDVNs, address[] optionalDVNs)", -); - -export interface UlnConfig { - confirmations: bigint; - requiredDVNCount: number; - optionalDVNCount: number; - optionalDVNThreshold: number; - requiredDVNs: string[]; - optionalDVNs: string[]; -} - -export interface LiveReadResult { - ok: boolean; - config?: UlnConfig; - error?: string; - endpoint?: string; - rpc_used?: string; - destination_eid?: number; -} - -function pickRpcUrls(chain: string): string[] { - if (chain === "ethereum") return getEthRpcEndpoints(); - return []; -} - -function decodeConfig(raw: `0x${string}`): UlnConfig | null { - try { - const [decoded] = decodeAbiParameters(ULN_CONFIG_PARAMS, raw) as [ - { - confirmations: bigint; - requiredDVNCount: number; - optionalDVNCount: number; - optionalDVNThreshold: number; - requiredDVNs: readonly `0x${string}`[]; - optionalDVNs: readonly `0x${string}`[]; - }, - ]; - return { - confirmations: decoded.confirmations, - requiredDVNCount: Number(decoded.requiredDVNCount), - optionalDVNCount: Number(decoded.optionalDVNCount), - optionalDVNThreshold: Number(decoded.optionalDVNThreshold), - requiredDVNs: decoded.requiredDVNs.map((a) => a.toLowerCase()), - optionalDVNs: decoded.optionalDVNs.map((a) => a.toLowerCase()), - }; - } catch { - return null; - } -} - -export async function fetchLiveLayerZeroConfig( - oapp: string, - chain: string, -): Promise { - const lower = chain.toLowerCase(); - const endpoint = ENDPOINT_V2_BY_CHAIN[lower]; - const lib = SEND_ULN302_BY_CHAIN[lower]; - const eid = DEFAULT_DST_EID_BY_SRC[lower]; - const viemChain = VIEM_CHAIN_BY_NAME[lower]; - - if (!endpoint || !lib || !eid || !viemChain) { - return { ok: false, error: `Chain ${chain} not configured for live LayerZero read` }; - } - - const callData = encodeFunctionData({ - abi: GET_CONFIG_ABI, - functionName: "getConfig", - args: [ - oapp.toLowerCase() as `0x${string}`, - lib.toLowerCase() as `0x${string}`, - eid, - ULN_CONFIG_TYPE, - ], - }); - - const urls = pickRpcUrls(lower); - if (urls.length === 0) { - return { ok: false, error: `No RPC endpoint configured for ${chain}` }; - } - - for (const url of urls) { - try { - // viem chain types are narrow; we don't actually need chain-specific - // behaviour for an eth_call, so we cast to the loosest acceptable shape. - const client = createPublicClient({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - chain: viemChain as any, - transport: http(url, { timeout: 5000 }), - }); - const raw = (await client.call({ - to: endpoint.toLowerCase() as `0x${string}`, - data: callData, - })) as { data?: `0x${string}` }; - if (!raw.data || raw.data === "0x") continue; - const [innerBytes] = decodeAbiParameters( - parseAbiParameters("bytes"), - raw.data, - ) as [`0x${string}`]; - if (!innerBytes || innerBytes === "0x") continue; - const config = decodeConfig(innerBytes); - if (config && config.requiredDVNCount + config.optionalDVNCount > 0) { - return { - ok: true, - config, - endpoint, - rpc_used: new URL(url).host, - destination_eid: eid, - }; - } - } catch { - continue; - } - } - - return { ok: false, error: "No RPC returned a decodable UlnConfig" }; -} diff --git a/apps/api/src/web3-assurance/methodology.ts b/apps/api/src/web3-assurance/methodology.ts deleted file mode 100644 index b947952c..00000000 --- a/apps/api/src/web3-assurance/methodology.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * Web3 Assurance — public methodology endpoint. - * - * Per DEC-20260428-B engineering bar: regulatory-grade builds require a - * public methodology page. Compliance officers and regulators expect to - * see exactly which evidence types are surfaced, where each one comes - * from, how the verdict is computed, and what the SLA commitments are. - * - * GET /v1/web3-assurance/methodology - * - * Returns the canonical specification: - * - All registered evaluators with priority + source + cache TTL - * - Reason-code vocabulary (CRITICAL + REVIEW + positive) - * - Verdict-computation rules - * - SLA tiers per mode - * - Source-list manifest (every external API/MCP we may call) - * - Versioning policy - */ - -import { Hono } from "hono"; -import { getEvaluators } from "./evaluators/index.js"; -import { getWeb3AssuranceSla } from "./routes.js"; -import { getAllSourceSqs } from "./source-quality.js"; -import { LAYERZERO_OAPPS, isReputableDvn } from "./data/layerzero-oapps.js"; -import type { AppEnv } from "../types.js"; - -export const methodologyRoute = new Hono(); -export const sourceQualityRoute = new Hono(); -export const bridgeConfigIndexRoute = new Hono(); - -bridgeConfigIndexRoute.get("/", (c) => { - const ranked = [...LAYERZERO_OAPPS] - .map((entry) => ({ - protocol_name: entry.protocol_name, - address: entry.address, - chain: entry.chain, - category: entry.category, - verification_protocol: "LayerZero V2 DVN", - required_dvn_count: entry.dvn_config.required_dvn_count, - optional_dvn_count: entry.dvn_config.optional_dvn_count, - optional_dvn_threshold: entry.dvn_config.optional_dvn_threshold, - total_dvn_count: - entry.dvn_config.required_dvns.length + entry.dvn_config.optional_dvns.length, - reputable_dvn_count: entry.reputable_dvn_count, - is_single_point_of_failure: entry.is_single_point_of_failure, - spof_modes: entry.spof_modes, - historical_incidents_count: entry.historical_incidents.length, - historical_incidents_total_lost_usd: entry.historical_incidents.reduce( - (sum, inc) => sum + inc.amount_usd, - 0, - ), - config_last_verified_at: entry.config_last_verified_at, - notes: entry.notes, - })) - .sort((a, b) => { - if (a.is_single_point_of_failure && !b.is_single_point_of_failure) return -1; - if (!a.is_single_point_of_failure && b.is_single_point_of_failure) return 1; - return b.historical_incidents_total_lost_usd - a.historical_incidents_total_lost_usd; - }); - - const reputableDvnList = [ - "LayerZero Labs", - "Google Cloud", - "Polyhedra", - "Nethermind", - "Stargate", - "P2P", - "Animoca", - "Restake", - "BCW", - "Switchboard", - "Horizen Labs", - ].filter((d) => isReputableDvn(d)); - - return c.json( - { - product: "Web3 Assurance — Bridge-Config Risk Index", - version: "v0.1", - published_at: new Date().toISOString(), - summary: - "Strale's curated index of LayerZero OApp DVN configurations. Surfaces single-point-of-failure modes (e.g. KelpDAO 1-of-1 DVN, $292M April 2026). v0.2 will replace the seed with live on-chain endpoint.getConfig reads (already live as a fallback in the bridge-config-risk evaluator).", - methodology: { - verification_protocol: "LayerZero V2 DVN configuration as read from EndpointV2.getConfig", - single_point_of_failure_definition: - "requiredDVNCount === 1 AND optionalDVNCount === 0 AND optionalDVNThreshold === 0. Means a single DVN can attest cross-chain messages alone; if compromised, message verification fails.", - risk_levels: { - critical: "is_single_point_of_failure === true", - high: "requiredDVNCount < 2", - medium: "reputable_dvn_count / total_dvn_count < 0.5", - low: "all required DVNs are reputable AND requiredDVNCount >= 2", - }, - }, - reputable_dvns: reputableDvnList, - sort_order: - "Single-point-of-failure entries first, then by total historical USD loss across documented incidents.", - entries: ranked, - contact: { email: "hello@strale.io" }, - }, - 200, - { - "Cache-Control": "public, max-age=300", - "Access-Control-Allow-Origin": "*", - }, - ); -}); - -sourceQualityRoute.get("/", (c) => { - return c.json( - { - product: "Web3 Assurance", - window: "rolling 100 calls per source (in-memory; resets on restart in v0.1)", - generated_at: new Date().toISOString(), - methodology: "composite_score = round(success_rate * 70 + latency_grade * 30); latency_grade derived from p95.", - sources: getAllSourceSqs(), - }, - 200, - { - "Cache-Control": "public, max-age=60", - "Access-Control-Allow-Origin": "*", - }, - ); -}); - -const API_VERSION = "0.1"; - -const REASON_CODE_GLOSSARY: Record = { - SANCTIONS_MATCH: { - severity: "critical", - description: "Counterparty matched against OFAC SDN, UN, EU, OFSI, or Swiss SECO sanctions lists.", - }, - WALLET_MALICIOUS: { - severity: "critical", - description: "GoPlus address-security flagged the wallet as malicious.", - }, - WALLET_PHISHING: { severity: "critical", description: "Wallet linked to phishing activity." }, - WALLET_MONEY_LAUNDERING: { severity: "critical", description: "Wallet associated with money-laundering activity." }, - WALLET_FINANCIAL_CRIME: { severity: "critical", description: "Wallet associated with financial crime." }, - WALLET_CYBERCRIME: { severity: "critical", description: "Wallet associated with cybercrime." }, - WALLET_DARKWEB: { severity: "critical", description: "Wallet associated with darkweb transactions." }, - WALLET_BLACKLIST: { severity: "review", description: "Wallet on a third-party blacklist." }, - WALLET_FAKE_KYC: { severity: "review", description: "Wallet associated with fake-KYC patterns." }, - WALLET_MALICIOUS_MINING: { severity: "review", description: "Wallet associated with malicious mining activity." }, - TOKEN_HONEYPOT: { severity: "critical", description: "Token contract is a honeypot (cannot sell)." }, - TOKEN_SELL_TAX_EXTREME: { severity: "critical", description: "Sell tax exceeds 50%." }, - TOKEN_SELL_TAX_HIGH: { severity: "review", description: "Sell tax between 10% and 50%." }, - TOKEN_HIDDEN_OWNER: { severity: "review", description: "Token has a hidden owner who can modify behaviour." }, - TOKEN_RECLAIMABLE_OWNERSHIP: { severity: "review", description: "Token allows the original deployer to reclaim ownership." }, - TOKEN_BLACKLISTED: { severity: "review", description: "Token is on a security blacklist." }, - TOKEN_CLOSED_SOURCE: { severity: "review", description: "Token contract source is not verified / open." }, - CONTRACT_UNVERIFIED: { severity: "review", description: "Contract source code is not verified on Etherscan or Sourcify." }, - CONTRACT_PROXY_NO_IMPL: { severity: "review", description: "Contract is a proxy with no resolvable implementation address." }, - PROTOCOL_RECENT_EXPLOIT_30D: { severity: "critical", description: "Protocol exploited within the last 30 days." }, - PROTOCOL_RECENT_EXPLOIT_90D: { severity: "review", description: "Protocol exploited within the last 90 days." }, - PROTOCOL_REPEAT_EXPLOITED: { severity: "review", description: "Protocol has 3+ documented incidents in DefiLlama Hacks DB." }, - PROTOCOL_NOT_INDEXED: { severity: "review", description: "Protocol target not found in DefiLlama. Treat as unknown, not safe." }, - MIXER_SANCTIONED: { severity: "critical", description: "Address is a currently-sanctioned mixer (e.g. Sinbad)." }, - MIXER_HIGH_RISK: { severity: "critical", description: "Address is a documented high-risk mixer." }, - MIXER_DELISTED_ELEVATED: { - severity: "review", - description: - "Address is a previously-sanctioned mixer (e.g. Tornado Cash, delisted by OFAC March 2025). Graded risk per Treasury 2026 mixer guidance.", - }, - MIXER_DELISTED: { severity: "review", description: "Address is a delisted mixer." }, - MIXER_UNCLASSIFIED: { severity: "review", description: "Address is in the mixer corpus but unclassified." }, - SCAM_CLUSTER_MATCH: { severity: "critical", description: "Address matches ScamSniffer phishing-wallet cluster." }, - APPROVALS_RISKY: { severity: "review", description: "Wallet has ≥1 risky token approvals." }, - BRIDGE_SINGLE_POINT_OF_FAILURE: { - severity: "critical", - description: - "Bridge has single-point-of-failure in its verification setup (e.g. KelpDAO 1-of-1 DVN configuration on LayerZero, $292M April 2026).", - }, - BRIDGE_CONFIG_CRITICAL: { severity: "critical", description: "Bridge configuration is classified as critical-risk." }, - BRIDGE_CONFIG_HIGH_RISK: { severity: "review", description: "Bridge configuration is classified as high-risk." }, - BRIDGE_SINGLE_REQUIRED_DVN: { severity: "review", description: "Bridge requires only 1 DVN (no redundancy)." }, - BRIDGE_RECENT_INCIDENT_365D: { severity: "critical", description: "Bridge has had a documented incident in the last 365 days." }, - EXPOSURE_DEPENDENCY_RECENT_EXPLOIT_90D: { - severity: "critical", - description: - "A protocol this target depends on (parent / fork / oracle) was exploited in the last 90 days. Cascading exposure risk.", - }, - EXPOSURE_DEPENDENCY_EXPLOITED_YEAR: { - severity: "review", - description: "A protocol dependency was exploited in the last 365 days.", - }, - EXPOSURE_UNKNOWN_DEPENDENCIES: { - severity: "review", - description: "Target has dependencies (parent / fork / oracle) that are not in the reputable set.", - }, - EXPOSURE_DEPENDENCY_HAS_HISTORY: { - severity: "review", - description: "A documented hack/incident exists for this target's dependency chain at any time.", - }, - EXPOSURE_UNKNOWN_ORACLE: { - severity: "review", - description: "Target depends on at least one oracle outside the reputable set (Chainlink / Pyth / RedStone / API3 / UMA / Tellor).", - }, - VELOCITY_BOT_PATTERN: { - severity: "review", - description: "Wallet's median inter-transaction interval is <30s with ≥5 sample transactions. Indicates automated / bot activity.", - }, - VELOCITY_SWEEP_PATTERN: { - severity: "review", - description: "≥50% of inbound transfers are followed by an outbound transfer within 60 seconds. Sweep-bot behavior; common in drainer flows.", - }, - VELOCITY_DORMANT_THEN_ACTIVE: { - severity: "review", - description: "Wallet was dormant for >90 days then became active in the last 7 days. Common pattern for compromised or sleeper accounts.", - }, - STABLECOIN_NON_MICA_ONLY: { - severity: "review", - description: "All stablecoin holdings are issued by non-MiCA-authorised issuers (e.g. USDT, FDUSD). MiCA Q3 2026 enforcement may restrict EU CASP-handling.", - }, - STABLECOIN_NON_MICA_PARTIAL: { - severity: "review", - description: "Some stablecoin holdings are non-MiCA-authorised. Partial regulatory exposure for EU recipients.", - }, - STABLECOIN_MICA_REVIEW_RECOMMENDED: { - severity: "review", - description: "Wallet holds at least one stablecoin requiring MiCA-jurisdiction review for EU receipt.", - }, - BYTECODE_RUG_MATCH: { - severity: "critical", - description: - "Deployed bytecode (after metadata normalization) exact-matches a known-rug pattern in Strale's curated rug-bytecode index. Same code as a previously-rugged contract.", - }, -}; - -const POSITIVE_REASON_CODES: Record = { - WALLET_HISTORY_CLEAN: "GoPlus address-security found no malicious indicators on the wallet.", - WALLET_AGE_ESTABLISHED: "Wallet has documented activity history (not freshly created).", - TOKEN_SAFETY_OK: "GoPlus token-security found no critical issues with the token contract.", - CONTRACT_VERIFIED: "Contract source code is verified.", - SCAM_CLUSTER_NO_MATCH: "Wallet is not in the ScamSniffer phishing-cluster corpus.", - MIXER_NO_MATCH: "Wallet is not a known mixer address.", -}; - -methodologyRoute.get("/", (c) => { - const evaluators = getEvaluators().map((e) => ({ - name: e.name, - priority: e.priority, - cache_ttl_seconds: e.cacheTTLSeconds, - })); - - const sources = [...new Set(getEvaluators().map((e) => e.name))] - .map((name) => methodologySource(name)) - .filter((s): s is NonNullable => s !== null); - - return c.json( - { - product: "Web3 Assurance", - api_version: API_VERSION, - published_at: new Date().toISOString(), - summary: - "Decision-ready answer about an on-chain counterparty (wallet, contract, token, DeFi protocol, or bridge) in a single auditable call. Sister product to Payee Assurance for off-chain KYB.", - response_envelope: { - verdict: "proceed | review | block | insufficient_evidence", - reason_codes: "string[] — UPPERCASE_SNAKE_CASE machine-parsable codes (see reason_code_glossary)", - confidence: "number 0..1", - evidence_completeness: "complete | partial | minimal", - evidence_status: "corroborated | partial | contradictory | single_source | minimal", - critical_flags: "string[] — human-readable namespaced flags", - suggested_action: "string — natural-language operator hint", - expires_at: "ISO 8601 datetime", - evidence: "object — per-evaluator evidence map", - source_quality: "array — per-source latency + ok", - disagreements: - "array — explicit cross-vendor disagreements (e.g. Sourcify says verified but Etherscan disagrees). Surfaces conflicts single-source competitors cannot detect.", - explanation_chain: - "array of {reason_code, severity, source_evaluator, evidence_excerpt, why} — structured causal chain. For each fired reason_code, walks back to the evaluator that produced it, the specific evidence values that triggered it, and a why-it-matters string. Workflow gates and human reviewers consume this directly.", - audit_url: "string — sidecar URL to hash-chained audit record (HMAC-signed, 90-day TTL)", - sla: { mode: "outbound | reverse-call", p99_ms: "integer", p50_ms: "integer" }, - }, - modes: { - outbound: { - description: - "Agent vetting recipient pre-payment. Full evaluator set runs. 8s budget per evaluator.", - sla: getWeb3AssuranceSla("outbound"), - }, - "reverse-call": { - description: - "x402 service publisher gating an inbound buyer in real-time. Critical evaluators only. 600ms cap per evaluator.", - sla: getWeb3AssuranceSla("reverse-call"), - }, - }, - verdict_logic: { - block: "Triggered when ≥1 reason_code is in the CRITICAL set (see reason_code_glossary).", - review: - "Triggered when no CRITICAL reason_codes are set but ≥1 reason_code is in the REVIEW set.", - insufficient_evidence: - "Triggered when no CRITICAL or REVIEW codes fire but evidence completeness is 'minimal'.", - proceed: "Triggered when no CRITICAL or REVIEW codes fire and evidence is 'partial' or 'complete'.", - }, - evaluators, - reason_code_glossary: REASON_CODE_GLOSSARY, - positive_reason_codes: POSITIVE_REASON_CODES, - sources, - regulatory_posture: { - ofac: - "OFAC SDN + crypto-specific addresses screened. Tornado Cash treated as graded (delisted March 2025) per Treasury 2026 guidance — not binary-blocked.", - mica: - "EU MiCA full enforcement July 1 2026. Counterparty wallet screening + Travel Rule transmission supported via reverse-call mode.", - fatf: "FATF Travel Rule jurisdiction-aware verdict surface available via caller_jurisdiction parameter.", - gdpr_art_22: "Verdicts surface critical_flags + suggested_action so the agent can act on documented evidence.", - }, - versioning_policy: { - api_version: API_VERSION, - breaking_change_policy: "Breaking response-shape changes increment the major version. New reason_codes or evidence fields are additive within a major.", - substrate_changes: - "Material changes to verdict logic or evaluator set are surfaced via response-header X-Strale-Methodology-Hash, which downstream consumers can monitor for drift.", - }, - audit_trail_policy: { - chain: "SHA-256 hash chain, per-day, anchored to GENESIS_HASH = sha256('strale-genesis-v1').", - token: "audit_url uses HMAC-SHA256(secret, `${recordId}:${expiresAt}`) signing with 90-day TTL.", - retention: "Indefinite for completed records.", - replay_capability: "Each record can be replayed to confirm the evidence trail available at the time the verdict was issued.", - }, - contact: { email: "hello@strale.io", docs: "https://strale.dev/docs" }, - }, - 200, - { - "Cache-Control": "public, max-age=300", - "Access-Control-Allow-Origin": "*", - }, - ); -}); - -function methodologySource(evaluatorName: string): { evaluator: string; primary_source: string; license: string } | null { - const sources: Record = { - "wallet-identity": { primary_source: "Etherscan API", license: "Etherscan ToS" }, - "wallet-history-risk": { primary_source: "GoPlus address-security API", license: "GoPlus public API" }, - "wallet-transactions": { primary_source: "Etherscan API", license: "Etherscan ToS" }, - "wallet-balance": { primary_source: "Etherscan API", license: "Etherscan ToS" }, - "token-safety": { primary_source: "GoPlus token-security API", license: "GoPlus public API" }, - "contract-verification": { primary_source: "Etherscan getsourcecode + Sourcify", license: "Etherscan ToS + Apache 2.0 (Sourcify)" }, - "approval-inventory": { primary_source: "GoPlus token-approval-security API", license: "GoPlus public API" }, - sanctions: { primary_source: "Dilisense (consolidated) + direct OFAC/UN/EU/OFSI lists", license: "Dilisense ToS" }, - "protocol-risk": { primary_source: "DefiLlama protocols + hacks DB", license: "DefiLlama public API" }, - "bridge-legitimacy": { primary_source: "DefiLlama bridges + L2Beat", license: "DefiLlama + L2Beat public" }, - "bridge-config-risk": { - primary_source: "Strale-curated LayerZero seed + LayerZero V2 endpoint.getConfig (live in v0.2)", - license: "Strale-curated; on-chain reads", - }, - "sourcify-verification": { primary_source: "Sourcify decentralized contract verification", license: "Apache 2.0" }, - "mixer-graded": { primary_source: "Strale-curated mixer corpus", license: "Strale-curated" }, - "scam-cluster": { primary_source: "ScamSniffer scam-database (GitHub)", license: "MIT" }, - "eas-attestations": { primary_source: "Ethereum Attestation Service GraphQL", license: "EAS public-good infrastructure" }, - "erc-8004-reputation": { primary_source: "On-chain reads via configured ERC-8004 registry", license: "On-chain public data" }, - "sister-rug": { primary_source: "Etherscan + DefiLlama Hacks DB cross-reference", license: "Etherscan ToS + DefiLlama public" }, - "web3-antivirus-risk": { primary_source: "Web3 Antivirus public API", license: "Web3 Antivirus public API" }, - "pre-trade-simulation": { primary_source: "Tenderly Simulation API", license: "Tenderly free tier" }, - "rekt-database": { primary_source: "de.fi REKT Database (token-gated)", license: "de.fi API" }, - "audit-firms": { primary_source: "Strale-curated audit-firm seed (Certik/OZ/TOB/Cyfrin/Sherlock/Code4rena)", license: "Public audit reports" }, - "cross-protocol-exposure": { - primary_source: "DefiLlama protocols + protocol detail + hacks DB (1-hop composability via parent/fork/oracle dependencies)", - license: "DefiLlama public API", - }, - "wallet-velocity": { - primary_source: "Etherscan tx history (analyzed for velocity / sweep / dormancy patterns)", - license: "Etherscan ToS", - }, - "stablecoin-issuer": { - primary_source: "Etherscan tokentx + Strale-curated stablecoin issuer registry (Circle, Tether, MakerDAO, Paxos, FirstDigital, Ethena Labs)", - license: "Etherscan ToS + public regulator filings", - }, - "bytecode-similarity": { - primary_source: "On-chain eth_getCode + Strale-curated rug-bytecode index (v0.1 exact-match; v0.2 fuzzy similarity)", - license: "On-chain public data; Strale-curated index", - }, - }; - const src = sources[evaluatorName]; - if (!src) return null; - return { evaluator: evaluatorName, ...src }; -} diff --git a/apps/api/src/web3-assurance/middlewares/agentkit.ts b/apps/api/src/web3-assurance/middlewares/agentkit.ts deleted file mode 100644 index 794ac3c2..00000000 --- a/apps/api/src/web3-assurance/middlewares/agentkit.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Web3 Assurance — Coinbase AgentKit drop-in. - * - * Coinbase AgentKit (cdp.coinbase.com) is the natural surface for x402 + - * Agentic Wallet builders. Two integration shapes: - * - * 1) Pre-action gate. Wrap an AgentKit action so it calls Strale on the - * recipient before executing the on-chain transaction. Block when the - * verdict says block. - * - * 2) Standalone tool. Add Web3 Assurance as a callable AgentKit action so - * the agent can ask "is this counterparty safe?" mid-conversation - * without committing to a transaction. - * - * v1 ships the pre-action gate. The standalone-tool form is a one-liner - * once AgentKit's tool registration matures (currently in flux). - */ - -import type { - Mode, - Verdict, - Web3AssuranceRequest, -} from "../types.js"; - -const DEFAULT_BASE_URL = "https://api.strale.io"; - -export interface AgentKitGateConfig { - apiKey?: string; - baseUrl?: string; - /** Default 'outbound' (recipient vetting). */ - mode?: Mode; - /** Block when verdict reaches this severity. Default 'block'. */ - blockOn?: "block" | "review"; - /** Min confidence to proceed. Default 0.5. */ - minConfidence?: number; - /** - * Pull the on-chain target from an AgentKit action call. AgentKit actions - * receive a structured input object; the gate inspects it to find the - * recipient address. Required. - */ - extractTarget: (input: unknown) => string | null; - /** Optional: enrich the request with target_type / chain / action / amount. */ - enrich?: (input: unknown) => Partial; -} - -export interface GateResult { - verdict: Verdict; - reason_codes: string[]; - critical_flags: string[]; - suggested_action: string; - audit_url: string; - confidence: number; - evidence: Record; - blocked: boolean; -} - -type AgentKitAction = (input: I) => Promise; - -export function withStraleWeb3Gate( - action: AgentKitAction, - config: AgentKitGateConfig, -): (input: I) => Promise { - const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; - const blockOn = config.blockOn ?? "block"; - const minConf = config.minConfidence ?? 0.5; - - return async (input: I): Promise => { - const target = config.extractTarget(input); - if (!target) { - return action(input); - } - - const enrichment = config.enrich ? config.enrich(input) : {}; - const body: Web3AssuranceRequest = { - target, - mode: config.mode ?? "outbound", - ...enrichment, - }; - - const headers: Record = { - "Content-Type": "application/json", - Accept: "application/json", - }; - if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; - - let response: Response; - try { - response = await fetch(`${baseUrl}/v1/web3-assurance`, { - method: "POST", - headers, - body: JSON.stringify(body), - signal: AbortSignal.timeout(5_000), - }); - } catch { - return action(input); - } - - if (!response.ok) { - return action(input); - } - - const data = (await response.json()) as { - verdict: Verdict; - reason_codes: string[]; - confidence: number; - critical_flags: string[]; - suggested_action: string; - evidence: Record; - audit_url: string; - }; - - const blocked = - data.verdict === "block" || - (blockOn === "review" && data.verdict === "review") || - data.confidence < minConf; - - if (blocked) { - return { - verdict: data.verdict, - reason_codes: data.reason_codes, - critical_flags: data.critical_flags, - suggested_action: data.suggested_action, - audit_url: data.audit_url, - confidence: data.confidence, - evidence: data.evidence, - blocked: true, - }; - } - - return action(input); - }; -} - -export type { Mode, Verdict, Web3AssuranceRequest }; diff --git a/apps/api/src/web3-assurance/middlewares/express.ts b/apps/api/src/web3-assurance/middlewares/express.ts deleted file mode 100644 index a8164edc..00000000 --- a/apps/api/src/web3-assurance/middlewares/express.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Web3 Assurance — Express drop-in middleware. - * - * Mirrors the Hono middleware (gate-outbound + gate-inbound) but for Express - * apps. Same config surface, same response-header conventions - * (X-Strale-Verdict, X-Strale-Confidence, X-Strale-Flags). - * - * import express from "express"; - * import { straleWeb3Guard } from "@strale/web3-assurance/express"; - * - * const app = express(); - * app.use(straleWeb3Guard({ - * mode: "gate-inbound", - * apiKey: process.env.STRALE_API_KEY, - * })); - */ - -import type { Mode, Verdict, Web3AssuranceRequest } from "../types.js"; - -const DEFAULT_BASE_URL = "https://api.strale.io"; - -export interface ExpressGuardConfig { - mode: "gate-outbound" | "gate-inbound"; - apiKey?: string; - baseUrl?: string; - blockOn?: "block" | "review"; - minConfidence?: number; - extractTarget?: (req: ExpressLikeRequest) => Promise | string | null; - context?: (req: ExpressLikeRequest) => Partial; -} - -interface ExpressLikeRequest { - headers: Record; - body?: unknown; - method?: string; - path?: string; -} - -interface ExpressLikeResponse { - setHeader(name: string, value: string): void; - status(code: number): ExpressLikeResponse; - json(body: unknown): ExpressLikeResponse; - locals?: Record; -} - -type ExpressNextFn = (err?: unknown) => void; - -function defaultExtractInbound(req: ExpressLikeRequest): string | null { - const sig = req.headers["x-payment-signature"]; - if (typeof sig === "string" && /0x[a-fA-F0-9]{40}/.test(sig)) { - return sig.match(/0x[a-fA-F0-9]{40}/)![0]; - } - const payer = req.headers["x-payment-payer"]; - if (typeof payer === "string" && /^0x[a-fA-F0-9]{40}$/.test(payer)) return payer; - return null; -} - -export function straleWeb3Guard(config: ExpressGuardConfig) { - const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; - const blockOn = config.blockOn ?? "block"; - const minConf = config.minConfidence ?? 0.5; - - return async (req: ExpressLikeRequest, res: ExpressLikeResponse, next: ExpressNextFn) => { - let target: string | null = null; - if (config.extractTarget) { - target = await config.extractTarget(req); - } else if (config.mode === "gate-inbound") { - target = defaultExtractInbound(req); - } - - if (!target) { - res.setHeader("X-Strale-Verdict", "skipped:no-target"); - return next(); - } - - const extra = config.context ? config.context(req) : {}; - const body: Web3AssuranceRequest = { - target, - mode: config.mode === "gate-inbound" ? "reverse-call" : "outbound", - ...extra, - }; - - const headers: Record = { - "Content-Type": "application/json", - Accept: "application/json", - }; - if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; - - let response: Response; - try { - response = await fetch(`${baseUrl}/v1/web3-assurance`, { - method: "POST", - headers, - body: JSON.stringify(body), - signal: AbortSignal.timeout(5_000), - }); - } catch (err) { - res.setHeader("X-Strale-Verdict", "skipped:fetch-error"); - return next(); - } - - if (!response.ok) { - res.setHeader("X-Strale-Verdict", `skipped:http-${response.status}`); - return next(); - } - - const data = (await response.json()) as { - verdict: Verdict; - reason_codes: string[]; - confidence: number; - critical_flags: string[]; - suggested_action: string; - audit_url: string; - evidence: Record; - }; - - res.setHeader("X-Strale-Verdict", data.verdict); - res.setHeader("X-Strale-Confidence", String(data.confidence)); - res.setHeader("X-Strale-Flags", data.critical_flags.slice(0, 10).join(",")); - res.setHeader("X-Strale-Audit-Url", data.audit_url); - - const shouldBlock = - data.verdict === "block" || - (blockOn === "review" && data.verdict === "review") || - data.confidence < minConf; - - if (shouldBlock) { - res - .status(403) - .json({ - error_code: "strale_blocked", - message: data.suggested_action, - verdict: data.verdict, - reason_codes: data.reason_codes, - confidence: data.confidence, - critical_flags: data.critical_flags, - audit_url: data.audit_url, - }); - return; - } - - if (res.locals) { - res.locals.strale_verdict = data.verdict; - res.locals.strale_evidence = data.evidence; - res.locals.strale_audit_url = data.audit_url; - } - next(); - }; -} - -export type { Mode }; diff --git a/apps/api/src/web3-assurance/middlewares/hono.ts b/apps/api/src/web3-assurance/middlewares/hono.ts deleted file mode 100644 index ba5e674f..00000000 --- a/apps/api/src/web3-assurance/middlewares/hono.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Web3 Assurance — Hono drop-in middleware. - * - * Two modes: - * gate-outbound — runs on a request that triggers an outbound on-chain - * action by your agent. Calls Strale on the *recipient* before allowing - * the action. Used by agent builders to prevent paying scam/sanctioned - * wallets. - * - * gate-inbound — runs on an x402 service publisher's endpoint. Extracts - * the x402 payer wallet from the request and calls Strale on it before - * delivering service. Used to filter scam-cluster traffic, drainer - * wallets, and sanctioned buyers before payment is even processed. - * - * v1 ships in-process inside the Strale codebase as a reference impl. For - * external distribution (npm @strale/web3-assurance-hono), this file is - * the source-of-truth and gets extracted to its own package post-PMF. - */ - -import type { Context, MiddlewareHandler } from "hono"; -import { compose, computeVerdict } from "../index.js"; -import type { Action, TargetType, Web3AssuranceRequest } from "../types.js"; - -export interface GuardConfig { - /** Mode: are we vetting where money is going (outbound) or where it's coming from (inbound)? */ - mode: "gate-outbound" | "gate-inbound"; - /** Block when the verdict is at or below this severity. Default: "block". */ - blockOn?: "block" | "review"; - /** Min confidence required to proceed. Default: 0.5. */ - minConfidence?: number; - /** Extract the target address from the request. Required for gate-outbound. */ - extractTarget?: (c: Context) => Promise | string | null; - /** Optional context decorator. */ - context?: (c: Context) => Partial; - /** Called when the request is blocked. Default: returns 403 with verdict body. */ - onBlock?: (c: Context, verdict: ReturnType) => Response; - /** Called when the verdict requires review (between block and proceed). */ - onReview?: (c: Context, verdict: ReturnType) => Response | void | Promise; -} - -const DEFAULT_BLOCK_ON: GuardConfig["blockOn"] = "block"; -const DEFAULT_MIN_CONFIDENCE = 0.5; - -function defaultExtractInbound(c: Context): string | null { - const sig = c.req.header("X-Payment-Signature") ?? c.req.header("x-payment-signature"); - if (sig && /0x[a-fA-F0-9]{40}/.test(sig)) { - return sig.match(/0x[a-fA-F0-9]{40}/)![0]; - } - const payerHeader = c.req.header("X-Payment-Payer") ?? c.req.header("x-payment-payer"); - if (payerHeader && /^0x[a-fA-F0-9]{40}$/.test(payerHeader)) return payerHeader; - return null; -} - -export function straleWeb3Guard(config: GuardConfig): MiddlewareHandler { - const blockOn = config.blockOn ?? DEFAULT_BLOCK_ON; - const minConf = config.minConfidence ?? DEFAULT_MIN_CONFIDENCE; - - return async (c, next) => { - let target: string | null = null; - if (config.extractTarget) { - target = await config.extractTarget(c); - } else if (config.mode === "gate-inbound") { - target = defaultExtractInbound(c); - } - - if (!target) { - c.header("X-Strale-Verdict", "skipped:no-target"); - await next(); - return; - } - - const extra = config.context ? config.context(c) : {}; - const composed = await compose({ - target, - mode: config.mode === "gate-inbound" ? "reverse-call" : "outbound", - ...extra, - }); - const verdict = computeVerdict(composed); - - c.header("X-Strale-Verdict", verdict.verdict); - c.header("X-Strale-Confidence", String(verdict.confidence)); - c.header("X-Strale-Flags", verdict.critical_flags.slice(0, 10).join(",")); - - const shouldBlock = - verdict.verdict === "block" || - (blockOn === "review" && verdict.verdict === "review") || - verdict.confidence < minConf; - - if (shouldBlock) { - if (config.onBlock) return config.onBlock(c, verdict); - return c.json( - { - error_code: "strale_blocked", - message: verdict.suggested_action, - verdict: verdict.verdict, - confidence: verdict.confidence, - critical_flags: verdict.critical_flags, - }, - 403, - ); - } - - if (verdict.verdict === "review" && config.onReview) { - const result = await config.onReview(c, verdict); - if (result) return result; - } - - c.set("strale_verdict", verdict); - c.set("strale_evidence", composed.evidence); - await next(); - }; -} - -export type { - Action, - TargetType, - Web3AssuranceRequest, -}; diff --git a/apps/api/src/web3-assurance/middlewares/langgraph.ts b/apps/api/src/web3-assurance/middlewares/langgraph.ts deleted file mode 100644 index 9fd191b2..00000000 --- a/apps/api/src/web3-assurance/middlewares/langgraph.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * Web3 Assurance — LangGraph drop-in node. - * - * Returns a LangGraph-compatible runnable node that gates outbound on-chain - * actions. Wire it into the graph before the action node: - * - * const graph = new StateGraph(MyAgentState) - * .addNode("preflight", straleWeb3Preflight({ apiKey, mode: "outbound" })) - * .addNode("send", sendNode) - * .addEdge("preflight", "send") - * .addConditionalEdges("preflight", (s) => - * s.strale_verdict === "block" ? "abort" : "send" - * ); - * - * The node mutates state with `strale_verdict`, `strale_reason_codes`, - * `strale_evidence`, and `strale_audit_url`. Downstream nodes can branch - * on the verdict. - * - * v1 ships in-process inside the Strale codebase as a reference impl. - * Post-PMF, extracts to its own package (@strale/web3-assurance-langgraph). - */ - -import type { - Mode, - TargetType, - Verdict, - Web3AssuranceRequest, -} from "../types.js"; - -const DEFAULT_BASE_URL = "https://api.strale.io"; - -export interface PreflightConfig { - apiKey?: string; - baseUrl?: string; - mode?: Mode; - /** Pull the target wallet/contract from the agent state. Required. */ - extractTarget: (state: Record) => string | null; - /** Optional: enrich the request with target_type / chain / action / amount. */ - enrich?: (state: Record) => Partial; - /** Block when verdict is at or below this severity. Default: "block". */ - blockOn?: "block" | "review"; - /** Min confidence to proceed. Default: 0.5. */ - minConfidence?: number; -} - -export interface PreflightStateUpdate { - strale_verdict: Verdict; - strale_reason_codes: string[]; - strale_evidence: Record; - strale_critical_flags: string[]; - strale_audit_url: string; - strale_confidence: number; - strale_should_proceed: boolean; -} - -export function straleWeb3Preflight(config: PreflightConfig) { - const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; - const blockOn = config.blockOn ?? "block"; - const minConf = config.minConfidence ?? 0.5; - - return async (state: Record): Promise => { - const target = config.extractTarget(state); - if (!target) { - return { - strale_verdict: "insufficient_evidence", - strale_reason_codes: ["NO_TARGET_PROVIDED"], - strale_evidence: {}, - strale_critical_flags: [], - strale_audit_url: "", - strale_confidence: 0, - strale_should_proceed: false, - }; - } - - const enrichment = config.enrich ? config.enrich(state) : {}; - const body: Web3AssuranceRequest = { - target, - mode: config.mode ?? "outbound", - ...enrichment, - }; - - const headers: Record = { - "Content-Type": "application/json", - Accept: "application/json", - }; - if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; - - const response = await fetch(`${baseUrl}/v1/web3-assurance`, { - method: "POST", - headers, - body: JSON.stringify(body), - signal: AbortSignal.timeout(5_000), - }); - - if (!response.ok) { - return { - strale_verdict: "insufficient_evidence", - strale_reason_codes: [`HTTP_${response.status}`], - strale_evidence: {}, - strale_critical_flags: [], - strale_audit_url: "", - strale_confidence: 0, - strale_should_proceed: false, - }; - } - - const data = (await response.json()) as { - verdict: Verdict; - reason_codes: string[]; - confidence: number; - critical_flags: string[]; - evidence: Record; - audit_url: string; - }; - - const blocked = - data.verdict === "block" || - (blockOn === "review" && data.verdict === "review") || - data.confidence < minConf; - - return { - strale_verdict: data.verdict, - strale_reason_codes: data.reason_codes, - strale_evidence: data.evidence, - strale_critical_flags: data.critical_flags, - strale_audit_url: data.audit_url, - strale_confidence: data.confidence, - strale_should_proceed: !blocked, - }; - }; -} - -export type { Mode, TargetType, Verdict, Web3AssuranceRequest }; diff --git a/apps/api/src/web3-assurance/routes.ts b/apps/api/src/web3-assurance/routes.ts deleted file mode 100644 index 3ea45717..00000000 --- a/apps/api/src/web3-assurance/routes.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Web3 Assurance — HTTP route handler. - * - * POST /v1/web3-assurance - * - * Response shape is agent-first per IBANforge feedback (2026-04-30): - * verdict + reason_codes + suggested_action surface at top level; the - * audit hash chain is a sidecar URL. Reason codes are stable, machine- - * parsable, UPPERCASE_SNAKE_CASE. - * - * Two modes: - * outbound — agent vetting recipient pre-payment. Full evaluator - * set, 8s budget. (Default) - * reverse-call — service publisher gating an inbound x402 buyer in - * real-time. Critical evaluators only, 600ms cap. - * - * Audit trail wiring is stubbed (audit_url returns "pending"); the next - * commit replaces it with the integrity-hash chain wrap. - */ - -import { Hono } from "hono"; -import { randomUUID } from "node:crypto"; -import { authMiddleware } from "../lib/middleware.js"; -import { rateLimitByKey } from "../lib/rate-limit.js"; -import { apiError } from "../lib/errors.js"; -import { getShareableUrl } from "../lib/audit-token.js"; -import type { AppEnv } from "../types.js"; -import { compose, computeVerdict } from "./index.js"; -import { detectDisagreements } from "./disagreement.js"; -import { buildExplanationChain } from "./explanation.js"; -import type { - Mode, - SlaSpec, - Web3AssuranceRequest, - Web3AssuranceResponse, -} from "./types.js"; - -export const web3AssuranceRoute = new Hono(); - -const API_VERSION = "0.1"; - -const SLA_BY_MODE: Record = { - outbound: { mode: "outbound", p99_ms: 8000, p50_ms: 1500 }, - "reverse-call": { mode: "reverse-call", p99_ms: 800, p50_ms: 250 }, -}; - -web3AssuranceRoute.post("/", authMiddleware, rateLimitByKey(10, 1000), async (c) => { - const start = Date.now(); - - const body = (await c.req.json().catch(() => null)) as Web3AssuranceRequest | null; - if (!body || typeof body !== "object") { - return c.json(apiError("invalid_request", "Request body is required."), 400); - } - if (!body.target || typeof body.target !== "string") { - return c.json( - apiError("invalid_request", "'target' is required (wallet address, contract, token, protocol slug, or domain)."), - 400, - ); - } - if (body.mode && body.mode !== "outbound" && body.mode !== "reverse-call") { - return c.json(apiError("invalid_request", "'mode' must be 'outbound' or 'reverse-call'."), 400); - } - - c.get("log").info( - { label: "web3-assurance-start", target: body.target, target_type: body.target_type, mode: body.mode }, - "web3-assurance-start", - ); - - const composed = await compose(body); - const verdict = computeVerdict(composed); - const disagreements = detectDisagreements(composed); - const explanationChain = buildExplanationChain(composed, verdict); - const mode = composed.context.mode; - const recordId = `wa_${randomUUID()}`; - const { url: auditUrl } = getShareableUrl(recordId); - - const sourceQuality = composed.results - .filter((r) => !r.skipped_reason) - .map((r) => ({ - source: r.provenance.source, - ms: r.ms, - ok: r.ok, - })); - - const response: Web3AssuranceResponse = { - target: composed.context.target, - target_type: composed.context.targetType, - chain: composed.context.chain, - mode, - verdict: verdict.verdict, - reason_codes: verdict.reason_codes, - confidence: verdict.confidence, - evidence_completeness: verdict.evidence_completeness, - evidence_status: verdict.evidence_status, - critical_flags: verdict.critical_flags, - suggested_action: verdict.suggested_action, - expires_at: verdict.expires_at, - evidence: composed.evidence, - source_quality: sourceQuality, - disagreements, - explanation_chain: explanationChain, - audit_url: auditUrl, - sla: SLA_BY_MODE[mode], - meta: { - api_version: API_VERSION, - fetched_at: new Date().toISOString(), - response_ms: Date.now() - start, - }, - }; - - c.get("log").info( - { - label: "web3-assurance-complete", - target: body.target, - mode, - verdict: verdict.verdict, - reason_codes_count: verdict.reason_codes.length, - evaluators_run: composed.results.filter((r) => !r.skipped_reason).length, - evaluators_skipped: composed.results.filter((r) => r.skipped_reason).length, - evaluators_ok: composed.results.filter((r) => r.ok).length, - response_ms: response.meta.response_ms, - }, - "web3-assurance-complete", - ); - - return c.json(response); -}); - -export function getWeb3AssuranceSla(mode: Mode = "outbound"): SlaSpec { - return SLA_BY_MODE[mode]; -} diff --git a/apps/api/src/web3-assurance/source-quality.ts b/apps/api/src/web3-assurance/source-quality.ts deleted file mode 100644 index 90db6890..00000000 --- a/apps/api/src/web3-assurance/source-quality.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Web3 Assurance — upstream source quality tracker. - * - * Per Tier-2 moat in the 2026-05-01 strategic deep-dive: every Web3 Assurance - * call records latency + success for every upstream source it touches. Over - * thousands of calls, Strale accumulates proprietary quality data on every - * external API/MCP it consumes (GoPlus, DefiLlama, Sourcify, Etherscan, - * Tenderly, EAS, ScamSniffer, etc.). Competitors can't catch up without - * the same call-history dataset. - * - * v0.1 storage: in-memory ring buffer per source. Survives until process - * restart. v0.2 will move to persistent storage (Postgres) and surface - * historical SQS via a public endpoint that becomes a category-defining - * artifact (vendors compete on Strale's score; consumers pick MCPs by it). - * - * Scoring methodology mirrors Strale's existing SQS engine (lib/sqs.ts): - * - Rolling window of last N calls per source (default: 100) - * - Per-source: success_rate, p50_ms, p95_ms, p99_ms, last_ok_at - * - Composite score 0..100 derived from success_rate × latency_grade - * - * Designed to be append-only and concurrency-safe (single Node process, - * synchronous push). For multi-worker scale, will move to a shared store. - */ - -const RING_SIZE = 100; - -interface CallRecord { - ms: number; - ok: boolean; - ts: number; -} - -interface RingBuffer { - records: CallRecord[]; - head: number; - size: number; -} - -const sourceRings = new Map(); - -function getOrCreateRing(source: string): RingBuffer { - let ring = sourceRings.get(source); - if (!ring) { - ring = { records: new Array(RING_SIZE), head: 0, size: 0 }; - sourceRings.set(source, ring); - } - return ring; -} - -export function recordSourceCall(source: string, ms: number, ok: boolean): void { - const ring = getOrCreateRing(source); - ring.records[ring.head] = { ms, ok, ts: Date.now() }; - ring.head = (ring.head + 1) % RING_SIZE; - if (ring.size < RING_SIZE) ring.size += 1; -} - -function percentile(sorted: number[], pct: number): number { - if (sorted.length === 0) return 0; - const idx = Math.min( - sorted.length - 1, - Math.max(0, Math.floor(sorted.length * pct)), - ); - return sorted[idx]; -} - -function gradeLatency(p95: number): number { - if (p95 < 200) return 100; - if (p95 < 500) return 90; - if (p95 < 1000) return 75; - if (p95 < 2000) return 60; - if (p95 < 5000) return 40; - return 20; -} - -export interface SourceSqs { - source: string; - sample_size: number; - success_rate: number; - p50_ms: number; - p95_ms: number; - p99_ms: number; - last_ok_at: string | null; - last_fail_at: string | null; - composite_score: number; -} - -export function getSourceSqs(source: string): SourceSqs | null { - const ring = sourceRings.get(source); - if (!ring || ring.size === 0) return null; - - const records = ring.records.slice(0, ring.size); - const okCount = records.filter((r) => r.ok).length; - const successRate = okCount / ring.size; - - const sortedLatencies = records.map((r) => r.ms).sort((a, b) => a - b); - const p50 = percentile(sortedLatencies, 0.5); - const p95 = percentile(sortedLatencies, 0.95); - const p99 = percentile(sortedLatencies, 0.99); - - const lastOk = records.filter((r) => r.ok).sort((a, b) => b.ts - a.ts)[0]; - const lastFail = records.filter((r) => !r.ok).sort((a, b) => b.ts - a.ts)[0]; - - const composite = Math.round(successRate * 100 * 0.7 + gradeLatency(p95) * 0.3); - - return { - source, - sample_size: ring.size, - success_rate: Math.round(successRate * 1000) / 1000, - p50_ms: p50, - p95_ms: p95, - p99_ms: p99, - last_ok_at: lastOk ? new Date(lastOk.ts).toISOString() : null, - last_fail_at: lastFail ? new Date(lastFail.ts).toISOString() : null, - composite_score: composite, - }; -} - -export function getAllSourceSqs(): SourceSqs[] { - return Array.from(sourceRings.keys()) - .map((s) => getSourceSqs(s)) - .filter((s): s is SourceSqs => s !== null) - .sort((a, b) => b.sample_size - a.sample_size); -} - -export function resetSourceQuality(): void { - sourceRings.clear(); -} diff --git a/apps/api/src/web3-assurance/trust-index.ts b/apps/api/src/web3-assurance/trust-index.ts deleted file mode 100644 index d01707ca..00000000 --- a/apps/api/src/web3-assurance/trust-index.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Strale Trust Index — public DeFi protocol ranking. - * - * GET /v1/web3-assurance/trust-index - * - * Tier-2 distribution play from the strategic memo: a public ranking of - * top DeFi protocols by Strale Web3 Assurance verdict. Recurring artifact - * for SEO + earned media + builder reference. - * - * v0.1 ships with a curated set of ~25 top protocols. Computes assurance - * verdicts lazily, caches for 6 hours. First request triggers a refresh; - * subsequent requests serve cached snapshot. Verdicts run in batches of 5 - * with 200ms spacing to stay friendly to upstream rate limits. - * - * v0.2: scheduled cron refresh + persistence to Postgres + historical - * trend lines per protocol. - */ - -import { Hono } from "hono"; -import { compose, computeVerdict } from "./index.js"; -import type { AppEnv } from "../types.js"; -import type { ComposeResult } from "./composer.js"; -import type { VerdictResult } from "./verdict.js"; - -const REFRESH_TTL_MS = 6 * 60 * 60 * 1000; -const BATCH_SIZE = 5; -const BATCH_DELAY_MS = 200; - -interface ProtocolEntry { - slug: string; - display_name: string; - category: string; - chain: string; -} - -const TOP_PROTOCOLS: ProtocolEntry[] = [ - { slug: "aave-v3", display_name: "Aave V3", category: "Lending", chain: "ethereum" }, - { slug: "compound-v3", display_name: "Compound V3", category: "Lending", chain: "ethereum" }, - { slug: "morpho", display_name: "Morpho", category: "Lending", chain: "ethereum" }, - { slug: "uniswap-v3", display_name: "Uniswap V3", category: "DEX", chain: "ethereum" }, - { slug: "uniswap-v4", display_name: "Uniswap V4", category: "DEX", chain: "ethereum" }, - { slug: "curve-dex", display_name: "Curve", category: "DEX", chain: "ethereum" }, - { slug: "balancer-v2", display_name: "Balancer V2", category: "DEX", chain: "ethereum" }, - { slug: "lido", display_name: "Lido", category: "LST", chain: "ethereum" }, - { slug: "rocket-pool", display_name: "Rocket Pool", category: "LST", chain: "ethereum" }, - { slug: "ether-fi-stake", display_name: "Ether.fi", category: "LRT", chain: "ethereum" }, - { slug: "renzo", display_name: "Renzo", category: "LRT", chain: "ethereum" }, - { slug: "kelpdao", display_name: "KelpDAO", category: "LRT", chain: "ethereum" }, - { slug: "eigenlayer", display_name: "EigenLayer", category: "Restaking", chain: "ethereum" }, - { slug: "pendle", display_name: "Pendle", category: "Yield", chain: "ethereum" }, - { slug: "stargate", display_name: "Stargate", category: "Bridge", chain: "ethereum" }, - { slug: "across", display_name: "Across", category: "Bridge", chain: "ethereum" }, - { slug: "hop-protocol", display_name: "Hop Protocol", category: "Bridge", chain: "ethereum" }, - { slug: "makerdao", display_name: "MakerDAO", category: "CDP", chain: "ethereum" }, - { slug: "spark", display_name: "Spark", category: "Lending", chain: "ethereum" }, - { slug: "convex-finance", display_name: "Convex", category: "Yield", chain: "ethereum" }, - { slug: "yearn-finance", display_name: "Yearn Finance", category: "Yield", chain: "ethereum" }, - { slug: "ondo-finance", display_name: "Ondo Finance", category: "RWA", chain: "ethereum" }, - { slug: "ethena", display_name: "Ethena", category: "Synthetic", chain: "ethereum" }, - { slug: "frax", display_name: "Frax", category: "Stablecoin", chain: "ethereum" }, - { slug: "sky-money", display_name: "Sky", category: "Stablecoin", chain: "ethereum" }, -]; - -interface TrustIndexEntry { - slug: string; - display_name: string; - category: string; - chain: string; - verdict: string; - confidence: number; - reason_codes: string[]; - critical_flags: string[]; - evidence_completeness: string; - computed_at: string; -} - -interface TrustIndexCache { - entries: TrustIndexEntry[]; - computed_at: string; - ts: number; -} - -let cache: TrustIndexCache | null = null; -let inFlight: Promise | null = null; - -function summarizeEntry( - entry: ProtocolEntry, - composed: ComposeResult, - verdict: VerdictResult, -): TrustIndexEntry { - return { - slug: entry.slug, - display_name: entry.display_name, - category: entry.category, - chain: entry.chain, - verdict: verdict.verdict, - confidence: verdict.confidence, - reason_codes: verdict.reason_codes, - critical_flags: verdict.critical_flags, - evidence_completeness: verdict.evidence_completeness, - computed_at: composed.context.target ? new Date().toISOString() : new Date().toISOString(), - }; -} - -async function delay(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -async function refresh(): Promise { - const entries: TrustIndexEntry[] = []; - for (let i = 0; i < TOP_PROTOCOLS.length; i += BATCH_SIZE) { - const batch = TOP_PROTOCOLS.slice(i, i + BATCH_SIZE); - const batchResults = await Promise.all( - batch.map(async (entry) => { - const composed = await compose({ - target: entry.slug, - target_type: "protocol", - chain: entry.chain, - }); - const verdict = computeVerdict(composed); - return summarizeEntry(entry, composed, verdict); - }), - ); - entries.push(...batchResults); - if (i + BATCH_SIZE < TOP_PROTOCOLS.length) await delay(BATCH_DELAY_MS); - } - - const next: TrustIndexCache = { - entries, - computed_at: new Date().toISOString(), - ts: Date.now(), - }; - cache = next; - return next; -} - -async function getOrRefresh(): Promise { - if (cache && Date.now() - cache.ts < REFRESH_TTL_MS) return cache; - if (inFlight) return inFlight; - inFlight = refresh().finally(() => { - inFlight = null; - }); - return inFlight; -} - -const VERDICT_ORDER: Record = { - block: 4, - review: 3, - insufficient_evidence: 2, - proceed: 1, -}; - -export const trustIndexRoute = new Hono(); - -trustIndexRoute.get("/", async (c) => { - const force = c.req.query("force") === "true"; - if (force) cache = null; - - let snapshot: TrustIndexCache; - try { - snapshot = await getOrRefresh(); - } catch (err) { - return c.json( - { - error: "trust_index_refresh_failed", - message: err instanceof Error ? err.message : "unknown", - }, - 503, - ); - } - - const sorted = [...snapshot.entries].sort((a, b) => { - const orderDiff = - (VERDICT_ORDER[b.verdict] ?? 0) - (VERDICT_ORDER[a.verdict] ?? 0); - if (orderDiff !== 0) return orderDiff; - return b.critical_flags.length - a.critical_flags.length; - }); - - const counts: Record = { - proceed: 0, - review: 0, - block: 0, - insufficient_evidence: 0, - }; - for (const e of sorted) counts[e.verdict] = (counts[e.verdict] ?? 0) + 1; - - return c.json( - { - product: "Strale Trust Index", - version: "v0.1", - universe_size: snapshot.entries.length, - computed_at: snapshot.computed_at, - verdict_counts: counts, - methodology: { - scope: "Top 25 DeFi protocols on Ethereum mainnet", - evaluator_set: "Full Web3 Assurance composer (25 evaluators, outbound mode)", - sort_order: "Highest-severity verdict first; ties broken by critical-flags count", - refresh_cadence: "Lazy-cached 6 hours; v0.2 will move to scheduled cron + persistent history", - }, - entries: sorted, - }, - 200, - { - "Cache-Control": "public, max-age=300", - "Access-Control-Allow-Origin": "*", - }, - ); -}); diff --git a/apps/api/src/web3-assurance/types.ts b/apps/api/src/web3-assurance/types.ts deleted file mode 100644 index fbca62ff..00000000 --- a/apps/api/src/web3-assurance/types.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Web3 Assurance — type definitions. - * - * Returns a decision-ready answer about an on-chain counterparty (wallet, - * contract, token, protocol, bridge, or domain). Sister product to - * Counterparty Assurance (off-chain KYB). The platform's sanctions - * substrate is reused here; everything else is crypto-native. - * - * Response shape is agent-first: verdict + reason_codes + suggested_action - * surface at top level. Audit trail demoted to sidecar audit_url. Per - * IBANforge feedback 2026-04-30 (see Journal). - */ - -export type TargetType = - | "wallet" - | "contract" - | "token" - | "protocol" - | "bridge" - | "domain"; - -export type Action = - | "send_payment" - | "swap" - | "stake" - | "mint" - | "interact" - | "bridge"; - -export type Verdict = "proceed" | "review" | "block" | "insufficient_evidence"; - -export type EvidenceCompleteness = "complete" | "partial" | "minimal"; - -export type Mode = "outbound" | "reverse-call"; - -export type EvaluatorPriority = "critical" | "opportunistic"; - -export interface Web3AssuranceRequest { - target: string; - target_type?: TargetType; - chain?: string; - action?: Action; - amount_usd?: number; - agent_id?: string; - caller_jurisdiction?: string; - mode?: Mode; -} - -export interface EvaluatorContext { - target: string; - targetType: TargetType; - chain: string; - action?: Action; - amountUsd?: number; - agentId?: string; - callerJurisdiction?: string; - mode: Mode; -} - -export interface Provenance { - source: string; - fetched_at: string; - [key: string]: unknown; -} - -export interface EvaluatorResult { - evaluator: string; - ok: boolean; - evidence: Record | null; - provenance: Provenance; - ms: number; - error?: string; - cached?: boolean; - skipped_reason?: string; -} - -export interface Evaluator { - name: string; - priority: EvaluatorPriority; - appliesTo: (ctx: EvaluatorContext) => boolean; - cacheKey: (ctx: EvaluatorContext) => string; - cacheTTLSeconds: number; - run: (ctx: EvaluatorContext) => Promise>; -} - -export interface SourceQualityEntry { - source: string; - sqs?: number; - ms: number; - ok: boolean; -} - -export interface DisagreementEntry { - class: string; - sources: string[]; - description: string; - resolution_hint: string; -} - -export interface ExplanationLink { - reason_code: string; - severity: "critical" | "review"; - source_evaluator: string; - evidence_excerpt: Record; - why: string; -} - -export interface SlaSpec { - mode: Mode; - p99_ms: number; - p50_ms: number; -} - -export interface Web3AssuranceResponse { - target: string; - target_type: TargetType; - chain: string; - mode: Mode; - verdict: Verdict; - reason_codes: string[]; - confidence: number; - evidence_completeness: EvidenceCompleteness; - evidence_status: "corroborated" | "partial" | "contradictory" | "single_source" | "minimal"; - critical_flags: string[]; - suggested_action: string; - expires_at: string; - evidence: Record | null>; - source_quality: SourceQualityEntry[]; - disagreements: DisagreementEntry[]; - explanation_chain: ExplanationLink[]; - audit_url: string; - sla: SlaSpec; - meta: { - api_version: string; - fetched_at: string; - response_ms: number; - }; -} diff --git a/apps/api/src/web3-assurance/verdict.ts b/apps/api/src/web3-assurance/verdict.ts deleted file mode 100644 index d1f72caa..00000000 --- a/apps/api/src/web3-assurance/verdict.ts +++ /dev/null @@ -1,364 +0,0 @@ -/** - * Web3 Assurance — decision-readiness logic. - * - * Reads the evidence map produced by the composer and emits a verdict - * (proceed / review / block / insufficient_evidence) plus structured fields - * the agent acts on. - * - * Rules are explicit and auditable. No model prediction; this is a rule - * engine over verifiable facts. Strict per the framework's E4 anti-rule. - */ - -import type { - ComposeResult, -} from "./composer.js"; -import type { - EvidenceCompleteness, - Verdict, -} from "./types.js"; - -const DEFAULT_VERDICT_TTL_SECONDS = 1800; - -export interface VerdictResult { - verdict: Verdict; - confidence: number; - evidence_completeness: EvidenceCompleteness; - evidence_status: "corroborated" | "partial" | "contradictory" | "single_source" | "minimal"; - critical_flags: string[]; - reason_codes: string[]; - suggested_action: string; - expires_at: string; -} - -function flagToReasonCode(flag: string): string { - return flag.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, ""); -} - -function getNum(value: unknown): number | null { - if (typeof value === "number") return value; - if (typeof value === "string") { - const n = parseFloat(value); - return Number.isFinite(n) ? n : null; - } - return null; -} - -function getBool(value: unknown): boolean { - return value === true || value === "true" || value === 1 || value === "1"; -} - -function flagsFromWallet(evidence: Record | null): string[] { - if (!evidence) return []; - const flags: string[] = []; - if (getBool(evidence.is_malicious)) flags.push("wallet:malicious"); - const labels = evidence.risk_labels; - if (Array.isArray(labels)) { - for (const label of labels) { - if (typeof label === "string") flags.push(`wallet:${label}`); - } - } - return flags; -} - -function flagsFromToken(evidence: Record | null): string[] { - if (!evidence) return []; - const flags: string[] = []; - if (getBool(evidence.is_honeypot)) flags.push("token:honeypot"); - if (getBool(evidence.hidden_owner)) flags.push("token:hidden_owner"); - if (getBool(evidence.can_take_back_ownership)) flags.push("token:reclaimable_ownership"); - if (getBool(evidence.is_blacklisted)) flags.push("token:blacklisted"); - const sellTax = getNum(evidence.sell_tax); - if (sellTax !== null && sellTax > 0.5) flags.push("token:sell_tax_extreme"); - if (sellTax !== null && sellTax > 0.1 && sellTax <= 0.5) flags.push("token:sell_tax_high"); - if (getBool(evidence.is_mintable)) flags.push("token:mintable"); - if (!getBool(evidence.is_open_source)) flags.push("token:closed_source"); - return flags; -} - -function flagsFromContract(evidence: Record | null): string[] { - if (!evidence) return []; - const flags: string[] = []; - if (evidence.is_verified === false) flags.push("contract:unverified"); - if (getBool(evidence.is_proxy) && !evidence.implementation_address) { - flags.push("contract:proxy_no_impl"); - } - return flags; -} - -function flagsFromApprovals(evidence: Record | null): string[] { - if (!evidence) return []; - const flags: string[] = []; - const risky = getNum(evidence.risky_approvals); - if (risky !== null && risky > 0) flags.push(`approvals:${risky}_risky`); - return flags; -} - -function flagsFromProtocol(evidence: Record | null): string[] { - if (!evidence) return []; - const flags: string[] = []; - if (evidence.found === false) flags.push("protocol:not_indexed"); - const incidents = evidence.incidents as Record | undefined; - if (incidents) { - const days = getNum(incidents.days_since_last_incident); - const count = getNum(incidents.count); - if (days !== null && days < 30) flags.push("protocol:recent_exploit_30d"); - else if (days !== null && days < 90) flags.push("protocol:recent_exploit_90d"); - if (count !== null && count >= 3) flags.push("protocol:repeat_exploited"); - } - if (getNum(evidence.audits_count) === 0) flags.push("protocol:no_audits"); - return flags; -} - -function flagsFromSanctions(evidence: Record | null): string[] { - if (!evidence) return []; - if (getBool(evidence.is_match) || getBool(evidence.matched)) { - return ["sanctions:match"]; - } - return []; -} - -function flagsFromMixer(evidence: Record | null): string[] { - if (!evidence) return []; - if (!getBool(evidence.is_known_mixer)) return []; - const category = typeof evidence.category === "string" ? evidence.category : "unknown"; - const weight = getNum(evidence.risk_weight) ?? 0; - if (category === "sanctioned") return ["mixer:sanctioned"]; - if (category === "high_risk") return ["mixer:high_risk"]; - if (category === "delisted") return weight >= 0.5 ? ["mixer:delisted_elevated"] : ["mixer:delisted"]; - if (category === "privacy") return ["mixer:privacy"]; - return ["mixer:unclassified"]; -} - -function flagsFromScamCluster(evidence: Record | null): string[] { - if (!evidence) return []; - return getBool(evidence.is_scam_cluster) ? ["scam:cluster_match"] : []; -} - -function flagsFromBytecodeSimilarity(evidence: Record | null): string[] { - if (!evidence) return []; - if (getBool(evidence.match_found)) return ["bytecode:rug_match"]; - return []; -} - -function flagsFromStablecoinIssuer(evidence: Record | null): string[] { - if (!evidence) return []; - if (evidence.enabled === false) return []; - const flags: string[] = []; - const level = evidence.issuer_risk_level; - if (level === "high") flags.push("stablecoin:non_mica_only"); - else if (level === "medium") flags.push("stablecoin:non_mica_partial"); - if (getBool(evidence.mica_q3_2026_relevant)) flags.push("stablecoin:mica_review_recommended"); - return flags; -} - -function flagsFromWalletVelocity(evidence: Record | null): string[] { - if (!evidence) return []; - if (evidence.no_activity === true || evidence.enabled === false) return []; - const flags: string[] = []; - const behavioral = evidence.behavioral_flags; - if (Array.isArray(behavioral)) { - if (behavioral.includes("velocity_bot_pattern")) flags.push("velocity:bot_pattern"); - if (behavioral.includes("sweep_pattern")) flags.push("velocity:sweep_pattern"); - if (behavioral.includes("dormant_then_active")) flags.push("velocity:dormant_then_active"); - } - return flags; -} - -function flagsFromCrossProtocolExposure(evidence: Record | null): string[] { - if (!evidence) return []; - if (evidence.found === false) return []; - const flags: string[] = []; - const level = evidence.exposure_risk_level; - if (level === "critical") flags.push("exposure:dependency_recent_exploit_90d"); - else if (level === "high") flags.push("exposure:dependency_exploited_year"); - else if (level === "medium") flags.push("exposure:unknown_dependencies"); - const lastHack = evidence.last_related_hack; - if (lastHack && typeof lastHack === "object") flags.push("exposure:dependency_has_history"); - const unknown = evidence.unknown_oracles; - if (Array.isArray(unknown) && unknown.length > 0) { - flags.push("exposure:unknown_oracle"); - } - return flags; -} - -function flagsFromBridgeConfig(evidence: Record | null): string[] { - if (!evidence) return []; - if (evidence.indexed === false) return []; - const flags: string[] = []; - if (getBool(evidence.is_single_point_of_failure)) { - flags.push("bridge:single_point_of_failure"); - } - if (getNum(evidence.required_dvn_count) === 1) { - flags.push("bridge:single_required_dvn"); - } - const recentIncidents = getNum(evidence.historical_incidents_recent_year) ?? 0; - if (recentIncidents > 0) { - flags.push("bridge:recent_incident_365d"); - } - if (evidence.risk_level === "critical") { - flags.push("bridge:config_critical"); - } else if (evidence.risk_level === "high") { - flags.push("bridge:config_high_risk"); - } - return flags; -} - -const CRITICAL_FLAGS = new Set([ - "sanctions:match", - "wallet:malicious", - "wallet:phishing", - "wallet:money_laundering", - "wallet:financial_crime", - "wallet:cybercrime", - "wallet:darkweb", - "token:honeypot", - "token:sell_tax_extreme", - "protocol:recent_exploit_30d", - "mixer:sanctioned", - "mixer:high_risk", - "scam:cluster_match", - "bridge:single_point_of_failure", - "bridge:config_critical", - "bridge:recent_incident_365d", - "exposure:dependency_recent_exploit_90d", - "bytecode:rug_match", -]); - -const REVIEW_FLAGS = new Set([ - "wallet:blacklist", - "wallet:fake_kyc", - "wallet:malicious_mining", - "token:hidden_owner", - "token:reclaimable_ownership", - "token:blacklisted", - "token:sell_tax_high", - "token:closed_source", - "contract:unverified", - "contract:proxy_no_impl", - "protocol:recent_exploit_90d", - "protocol:repeat_exploited", - "protocol:not_indexed", - "mixer:delisted_elevated", - "mixer:delisted", - "mixer:unclassified", - "bridge:single_required_dvn", - "bridge:config_high_risk", - "exposure:dependency_exploited_year", - "exposure:unknown_dependencies", - "exposure:dependency_has_history", - "exposure:unknown_oracle", - "velocity:sweep_pattern", - "velocity:dormant_then_active", - "velocity:bot_pattern", - "stablecoin:non_mica_only", - "stablecoin:non_mica_partial", - "stablecoin:mica_review_recommended", -]); - -function evaluatorOk(compose: ComposeResult, name: string): boolean { - return compose.results.find((r) => r.evaluator === name)?.ok === true; -} - -type EvidenceCorroboration = "corroborated" | "partial" | "contradictory" | "single_source" | "minimal"; - -function computeCompleteness( - compose: ComposeResult, -): { completeness: EvidenceCompleteness; corroboration: EvidenceCorroboration } { - const total = compose.results.length; - if (total === 0) return { completeness: "minimal", corroboration: "minimal" }; - - const okCount = compose.results.filter((r) => r.ok).length; - const ratio = okCount / total; - - let completeness: EvidenceCompleteness; - if (ratio >= 0.85) completeness = "complete"; - else if (ratio >= 0.5) completeness = "partial"; - else completeness = "minimal"; - - const expectedSources = compose.results - .filter((r) => r.ok) - .map((r) => r.provenance.source).filter((v, i, a) => a.indexOf(v) === i).length; - - let corroboration: EvidenceCorroboration; - if (expectedSources >= 3) corroboration = "corroborated"; - else if (expectedSources === 2) corroboration = "partial"; - else if (expectedSources === 1) corroboration = "single_source"; - else corroboration = "minimal"; - - return { completeness, corroboration }; -} - -export function computeVerdict(compose: ComposeResult): VerdictResult { - const flags: string[] = [ - ...flagsFromWallet(compose.evidence["wallet-history-risk"] ?? null), - ...flagsFromToken(compose.evidence["token-safety"] ?? null), - ...flagsFromContract(compose.evidence["contract-verification"] ?? null), - ...flagsFromApprovals(compose.evidence["approval-inventory"] ?? null), - ...flagsFromProtocol(compose.evidence["protocol-risk"] ?? null), - ...flagsFromSanctions(compose.evidence["sanctions"] ?? null), - ...flagsFromMixer(compose.evidence["mixer-graded"] ?? null), - ...flagsFromScamCluster(compose.evidence["scam-cluster"] ?? null), - ...flagsFromBridgeConfig(compose.evidence["bridge-config-risk"] ?? null), - ...flagsFromCrossProtocolExposure(compose.evidence["cross-protocol-exposure"] ?? null), - ...flagsFromWalletVelocity(compose.evidence["wallet-velocity"] ?? null), - ...flagsFromStablecoinIssuer(compose.evidence["stablecoin-issuer"] ?? null), - ...flagsFromBytecodeSimilarity(compose.evidence["bytecode-similarity"] ?? null), - ]; - - const critical = flags.filter((f) => CRITICAL_FLAGS.has(f)); - const review = flags.filter((f) => REVIEW_FLAGS.has(f)); - - const { completeness, corroboration } = computeCompleteness(compose); - - let verdict: Verdict; - if (critical.length > 0) verdict = "block"; - else if (review.length > 0) verdict = "review"; - else if (completeness === "minimal") verdict = "insufficient_evidence"; - else verdict = "proceed"; - - let confidence: number; - if (verdict === "block") confidence = 0.95; - else if (verdict === "insufficient_evidence") confidence = 0.4; - else if (completeness === "complete" && corroboration === "corroborated") confidence = 0.92; - else if (completeness === "complete") confidence = 0.85; - else if (completeness === "partial") confidence = 0.7; - else confidence = 0.5; - - let suggested_action: string; - if (verdict === "block") { - suggested_action = `Do not transact. ${critical.length} critical flag(s) detected: ${critical.slice(0, 3).join(", ")}.`; - } else if (verdict === "review") { - suggested_action = `Hold for human review. ${review.length} concerning flag(s): ${review.slice(0, 3).join(", ")}.`; - } else if (verdict === "insufficient_evidence") { - suggested_action = "Insufficient evidence to render a confident verdict. Provide more context (chain, target_type) or retry."; - } else { - suggested_action = "Evidence supports proceeding. No critical or concerning flags detected."; - } - - if (!evaluatorOk(compose, "sanctions")) { - suggested_action += " (sanctions evidence unavailable; verdict assumes no match — verify independently for high-value flows.)"; - } - - const allFlags = [...critical, ...review]; - const reason_codes = allFlags.map(flagToReasonCode); - - if (verdict === "proceed" && reason_codes.length === 0) { - if (evaluatorOk(compose, "wallet-history-risk")) reason_codes.push("WALLET_HISTORY_CLEAN"); - if (evaluatorOk(compose, "wallet-identity")) reason_codes.push("WALLET_AGE_ESTABLISHED"); - if (evaluatorOk(compose, "token-safety")) reason_codes.push("TOKEN_SAFETY_OK"); - if (evaluatorOk(compose, "contract-verification")) reason_codes.push("CONTRACT_VERIFIED"); - if (evaluatorOk(compose, "scam-cluster")) reason_codes.push("SCAM_CLUSTER_NO_MATCH"); - if (evaluatorOk(compose, "mixer-graded")) reason_codes.push("MIXER_NO_MATCH"); - } - - return { - verdict, - confidence: Math.round(confidence * 100) / 100, - evidence_completeness: completeness, - evidence_status: corroboration, - critical_flags: allFlags, - reason_codes, - suggested_action, - expires_at: new Date(Date.now() + DEFAULT_VERDICT_TTL_SECONDS * 1000).toISOString(), - }; -} diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index fbb9d477..7d552d3a 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -1121,91 +1121,7 @@ ACCESSING TRUST DATA }, ); - // Web3 Assurance — on-chain counterparty assurance (sister product to Payee Assurance) - server.registerTool( - "strale_web3_assurance", - { - description: - "Returns a decision-ready answer about an on-chain counterparty (wallet, smart contract, token, DeFi protocol, or bridge) in a single call. Surfaces verdict (proceed/review/block/insufficient_evidence), reason_codes (machine-parsable UPPERCASE_SNAKE_CASE), critical_flags, suggested_action, evidence map (sanctions, mixer-graded, scam-cluster, wallet-history, token-safety, contract-verification, protocol-risk, EAS attestations, ERC-8004 reputation, more), and a sidecar audit_url. Two modes: 'outbound' (agent vetting recipient pre-payment, full evaluator set, 8s budget) or 'reverse-call' (service publisher gating an inbound x402 buyer in real-time, critical evaluators only, sub-second SLA). Use before any agent transacts on-chain — sending value, swapping, staking, minting, bridging, or interacting with a contract.", - inputSchema: z.object({ - target: z - .string() - .describe( - "On-chain target. EVM wallet/contract/token (0x...), Solana address, or DeFi protocol slug (e.g. 'aave', 'uniswap-v3').", - ), - target_type: z - .enum(["wallet", "contract", "token", "protocol", "bridge", "domain"]) - .optional() - .describe( - "Target kind. Inferred when omitted: 0x... → wallet (default), .eth/.sol → wallet, slug → protocol.", - ), - chain: z - .string() - .optional() - .describe( - "Chain. EVM: 'ethereum' (default), 'base', 'polygon', 'arbitrum', 'optimism', 'bsc'. Or 'solana'.", - ), - action: z - .enum(["send_payment", "swap", "stake", "mint", "interact", "bridge"]) - .optional() - .describe( - "Optional intended action. When provided, enables pre-trade simulation (outbound mode) and tunes verdict severity.", - ), - amount_usd: z - .number() - .optional() - .describe("Optional amount in USD. Sharpens verdict for high-value flows."), - mode: z - .enum(["outbound", "reverse-call"]) - .optional() - .describe( - "Default 'outbound' (agent → recipient, 8s budget, all evidence). Use 'reverse-call' when you are an x402 service publisher gating an inbound buyer (critical evidence only, sub-second SLA).", - ), - agent_id: z - .string() - .optional() - .describe("Optional ERC-8004 agent identifier for the calling agent."), - caller_jurisdiction: z - .string() - .optional() - .describe("Optional ISO country code for jurisdiction-aware verdict (US, EU, UK, etc.)."), - }), - }, - async (input) => { - try { - const { data, status } = await stralePost>( - "/v1/web3-assurance", - input as Record, - { baseUrl: opts.baseUrl, apiKey: opts.apiKey }, - ); - if (status >= 400) { - return { - content: [ - { - type: "text" as const, - text: `Web3 Assurance ${status}: ${JSON.stringify(data).slice(0, 500)}`, - }, - ], - }; - } - return { - content: [ - { - type: "text" as const, - text: JSON.stringify(data, null, 2), - }, - ], - }; - } catch (err) { - return { - content: [ - { - type: "text" as const, - text: `Failed to call Web3 Assurance: ${err instanceof Error ? err.message : err}`, - }, - ], - }; - } - }, - ); + // Web3 Assurance retired 2026-05-04 — code deleted in lockstep with the + // archived product page (Notion). The MCP tool is unregistered; clients + // that previously called strale_web3_assurance now get tool-not-found. } diff --git a/packages/sdk-python/straleio/__init__.py b/packages/sdk-python/straleio/__init__.py index 3d702e70..80d8ca23 100644 --- a/packages/sdk-python/straleio/__init__.py +++ b/packages/sdk-python/straleio/__init__.py @@ -23,11 +23,11 @@ Transaction, TransactionDetail, ) -from .web3_assurance import ( - Web3AssuranceClient, - Web3AssuranceResult, - strale_web3_guard, -) + +# Web3 Assurance retired 2026-05-04. The Web3AssuranceClient module was +# deleted in lockstep with the backend code. Imports of +# `straleio.web3_assurance` will raise ImportError on this and later +# package versions. __all__ = [ # Client @@ -52,10 +52,6 @@ "RateLimitedError", "UnauthorizedError", "NotFoundError", - # Web3 Assurance - "Web3AssuranceClient", - "Web3AssuranceResult", - "strale_web3_guard", ] -__version__ = "0.1.1" +__version__ = "0.2.0" diff --git a/packages/sdk-python/straleio/web3_assurance.py b/packages/sdk-python/straleio/web3_assurance.py deleted file mode 100644 index 317c8b99..00000000 --- a/packages/sdk-python/straleio/web3_assurance.py +++ /dev/null @@ -1,205 +0,0 @@ -""" -Web3 Assurance — Python helpers + FastAPI drop-in. - -Python-side equivalent of the TypeScript drop-in middlewares (Hono / Express / -LangGraph / AgentKit). Provides: - -- ``Web3AssuranceClient`` — a small typed client for POST /v1/web3-assurance. -- ``strale_web3_guard`` — a FastAPI dependency that gates inbound x402 payers - or outbound on-chain actions, mirroring the Hono / Express middleware. - -Both surface the same response-header conventions as the TypeScript drop-ins -(X-Strale-Verdict, X-Strale-Confidence, X-Strale-Flags, X-Strale-Audit-Url). - -Ships in-process inside the Strale SDK as a reference implementation. Once -PMF lands, extracts to its own package (strale-web3-assurance-fastapi). -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Awaitable, Callable, Literal, Optional - -import httpx - -DEFAULT_BASE_URL = "https://api.strale.io" - -Mode = Literal["outbound", "reverse-call"] -Verdict = Literal["proceed", "review", "block", "insufficient_evidence"] - - -@dataclass -class Web3AssuranceResult: - verdict: Verdict - reason_codes: list[str] - confidence: float - critical_flags: list[str] - suggested_action: str - audit_url: str - evidence: dict[str, Any] - raw: dict[str, Any] - - -class Web3AssuranceClient: - """Thin client for POST /v1/web3-assurance.""" - - def __init__( - self, - api_key: Optional[str] = None, - base_url: str = DEFAULT_BASE_URL, - timeout_s: float = 10.0, - ): - self._api_key = api_key - self._base_url = base_url.rstrip("/") - self._timeout_s = timeout_s - - async def assess( - self, - target: str, - *, - target_type: Optional[str] = None, - chain: Optional[str] = None, - action: Optional[str] = None, - amount_usd: Optional[float] = None, - mode: Mode = "outbound", - agent_id: Optional[str] = None, - caller_jurisdiction: Optional[str] = None, - ) -> Web3AssuranceResult: - body: dict[str, Any] = {"target": target, "mode": mode} - for key, value in { - "target_type": target_type, - "chain": chain, - "action": action, - "amount_usd": amount_usd, - "agent_id": agent_id, - "caller_jurisdiction": caller_jurisdiction, - }.items(): - if value is not None: - body[key] = value - - headers: dict[str, str] = {"Accept": "application/json"} - if self._api_key: - headers["Authorization"] = f"Bearer {self._api_key}" - - async with httpx.AsyncClient(timeout=self._timeout_s) as client: - response = await client.post( - f"{self._base_url}/v1/web3-assurance", - json=body, - headers=headers, - ) - response.raise_for_status() - data = response.json() - - return Web3AssuranceResult( - verdict=data["verdict"], - reason_codes=data.get("reason_codes", []), - confidence=float(data.get("confidence", 0)), - critical_flags=data.get("critical_flags", []), - suggested_action=data.get("suggested_action", ""), - audit_url=data.get("audit_url", ""), - evidence=data.get("evidence", {}), - raw=data, - ) - - -def strale_web3_guard( - *, - mode: Literal["gate-outbound", "gate-inbound"], - api_key: Optional[str] = None, - base_url: str = DEFAULT_BASE_URL, - block_on: Literal["block", "review"] = "block", - min_confidence: float = 0.5, - extract_target: Optional[Callable[[Any], Awaitable[Optional[str]] | Optional[str]]] = None, -): - """FastAPI dependency that gates a request based on Web3 Assurance verdict. - - Usage:: - - from fastapi import FastAPI, Depends - from straleio.web3_assurance import strale_web3_guard - - app = FastAPI() - - guard = strale_web3_guard(mode="gate-inbound", api_key="sk_...") - - @app.post("/api/service", dependencies=[Depends(guard)]) - async def service(): - return {"ok": True} - - Sets X-Strale-Verdict / X-Strale-Confidence / X-Strale-Flags / X-Strale-Audit-Url - response headers and raises HTTPException(403, ...) when the verdict says block. - """ - from fastapi import HTTPException, Request, Response - - client = Web3AssuranceClient(api_key=api_key, base_url=base_url) - request_mode: Mode = "reverse-call" if mode == "gate-inbound" else "outbound" - - async def _default_extract_inbound(request: Request) -> Optional[str]: - sig = request.headers.get("x-payment-signature") or "" - import re - - m = re.search(r"0x[a-fA-F0-9]{40}", sig) - if m: - return m.group(0) - payer = request.headers.get("x-payment-payer") or "" - if re.fullmatch(r"0x[a-fA-F0-9]{40}", payer): - return payer - return None - - async def _dependency(request: Request, response: Response) -> None: - if extract_target is not None: - maybe = extract_target(request) - if hasattr(maybe, "__await__"): - target = await maybe # type: ignore[assignment] - else: - target = maybe # type: ignore[assignment] - elif mode == "gate-inbound": - target = await _default_extract_inbound(request) - else: - target = None - - if not target: - response.headers["X-Strale-Verdict"] = "skipped:no-target" - return - - try: - result = await client.assess(target=target, mode=request_mode) - except Exception as exc: # noqa: BLE001 - response.headers["X-Strale-Verdict"] = f"skipped:fetch-error" - response.headers["X-Strale-Error"] = str(exc)[:200] - return - - response.headers["X-Strale-Verdict"] = result.verdict - response.headers["X-Strale-Confidence"] = str(result.confidence) - response.headers["X-Strale-Flags"] = ",".join(result.critical_flags[:10]) - response.headers["X-Strale-Audit-Url"] = result.audit_url - - should_block = ( - result.verdict == "block" - or (block_on == "review" and result.verdict == "review") - or result.confidence < min_confidence - ) - if should_block: - raise HTTPException( - status_code=403, - detail={ - "error_code": "strale_blocked", - "message": result.suggested_action, - "verdict": result.verdict, - "reason_codes": result.reason_codes, - "confidence": result.confidence, - "critical_flags": result.critical_flags, - "audit_url": result.audit_url, - }, - ) - - return _dependency - - -__all__ = [ - "Web3AssuranceClient", - "Web3AssuranceResult", - "strale_web3_guard", - "Mode", - "Verdict", -]