From eae079a61f7504e339ff9f3ef931b8b3dcb75464 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 05:20:09 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Luca=20Skills=20V1=20=E2=80=94=206=20ca?= =?UTF-8?q?llable=20financial=20intelligence=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /api/luca/skills (discovery manifest) and 6 POST skill routes: - wallet-audit: classify an address, report books-compatibility, stable balance - agent-books: full financial statement (revenue/expenses/net) for any agent slug - treasury-monitor: USDC+USDT balance per wallet with health signal (healthy/low/critical) - revenue-analysis: gross_inflow vs operating_revenue breakdown with recognition rate and quarantine detail - registry-check: look up agent by slug, name, or wallet address; returns attribution tier and wallet eligibility - luca-report: composite — registry + attribution + books + treasury + luca_summary in one call All skills use v1Auth (Bearer/X-API-Key). Data integrity rules enforced: ERC-8004 = identity only, manifest = attribution, eoa/treasury_contract = books-eligible types, gross_inflow_usd ≠ revenue. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RHDXdEbGQsn88zks713gye --- src/app/api/luca/skills/agent-books/route.ts | 55 +++++++ src/app/api/luca/skills/luca-report/route.ts | 119 ++++++++++++++ .../api/luca/skills/registry-check/route.ts | 117 ++++++++++++++ .../api/luca/skills/revenue-analysis/route.ts | 104 +++++++++++++ src/app/api/luca/skills/route.ts | 89 +++++++++++ .../api/luca/skills/treasury-monitor/route.ts | 145 ++++++++++++++++++ src/app/api/luca/skills/wallet-audit/route.ts | 83 ++++++++++ 7 files changed, 712 insertions(+) create mode 100644 src/app/api/luca/skills/agent-books/route.ts create mode 100644 src/app/api/luca/skills/luca-report/route.ts create mode 100644 src/app/api/luca/skills/registry-check/route.ts create mode 100644 src/app/api/luca/skills/revenue-analysis/route.ts create mode 100644 src/app/api/luca/skills/route.ts create mode 100644 src/app/api/luca/skills/treasury-monitor/route.ts create mode 100644 src/app/api/luca/skills/wallet-audit/route.ts diff --git a/src/app/api/luca/skills/agent-books/route.ts b/src/app/api/luca/skills/agent-books/route.ts new file mode 100644 index 0000000..6dee123 --- /dev/null +++ b/src/app/api/luca/skills/agent-books/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { v1Auth } from "@/lib/v1-auth"; +import { buildAgentBooks, getAgentBySlug } from "@/lib/agent-books"; +import { ledgerErrorResponse } from "@/lib/api-utils"; +import type { TimeRange } from "@/lib/ledger"; + +export const dynamic = "force-dynamic"; + +const VALID_PERIODS = new Set(["7d", "14d", "30d", "90d"]); + +export async function POST(req: NextRequest) { + const auth = await v1Auth(req); + if (!auth.ok) return auth.response; + + const start = Date.now(); + const endpoint = "/api/luca/skills/agent-books"; + + let body: { slug?: string; period?: string }; + try { + body = await req.json() as { slug?: string; period?: string }; + } catch { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const slug = (body.slug ?? "").trim().toLowerCase(); + if (!slug) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "slug is required" }, { status: 400 }); + } + + const period = (body.period ?? "30d") as TimeRange; + if (!VALID_PERIODS.has(period)) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "period must be 7d, 14d, 30d, or 90d" }, { status: 400 }); + } + + try { + const agent = await getAgentBySlug(slug); + if (!agent) { + auth.finish(404, Date.now() - start, endpoint); + return NextResponse.json( + { error: `Agent '${slug}' not found. Use registry-check skill to find the correct slug.` }, + { status: 404 }, + ); + } + + const books = await buildAgentBooks(agent, period); + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ skill: "agent-books", ...books }); + } catch (error) { + auth.finish(500, Date.now() - start, endpoint); + return ledgerErrorResponse(error); + } +} diff --git a/src/app/api/luca/skills/luca-report/route.ts b/src/app/api/luca/skills/luca-report/route.ts new file mode 100644 index 0000000..f197532 --- /dev/null +++ b/src/app/api/luca/skills/luca-report/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; +import { v1Auth } from "@/lib/v1-auth"; +import { buildAgentBooks, getAgentBySlug } from "@/lib/agent-books"; +import { getWalletStableBalance } from "@/lib/treasury-balance"; +import { isBooksEligibleWallet } from "@/lib/wallet-eligibility"; +import { toSlug } from "@/app/registry/[slug]/slug"; +import { ledgerErrorResponse } from "@/lib/api-utils"; +import type { TimeRange } from "@/lib/ledger"; + +export const dynamic = "force-dynamic"; + +const VALID_PERIODS = new Set(["7d", "14d", "30d", "90d"]); + +function treasuryHealth(balance: number, hasWallets: boolean): string { + if (!hasWallets) return "unknown"; + if (balance >= 10_000) return "healthy"; + if (balance >= 1_000) return "low"; + return "critical"; +} + +export async function POST(req: NextRequest) { + const auth = await v1Auth(req); + if (!auth.ok) return auth.response; + + const start = Date.now(); + const endpoint = "/api/luca/skills/luca-report"; + + let body: { slug?: string; period?: string }; + try { + body = await req.json() as { slug?: string; period?: string }; + } catch { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const slug = (body.slug ?? "").trim().toLowerCase(); + if (!slug) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "slug is required" }, { status: 400 }); + } + + const period = (body.period ?? "30d") as TimeRange; + if (!VALID_PERIODS.has(period)) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "period must be 7d, 14d, 30d, or 90d" }, { status: 400 }); + } + + try { + const agent = await getAgentBySlug(slug); + if (!agent) { + auth.finish(404, Date.now() - start, endpoint); + return NextResponse.json({ error: `Agent '${slug}' not found` }, { status: 404 }); + } + + const allWallets = agent.wallets ?? []; + const eligibleWallets = allWallets.filter((w) => isBooksEligibleWallet(w, agent.tokenAddress).eligible); + const attributionTier = + eligibleWallets.length > 0 ? "manifest_attributed" : + allWallets.length > 0 ? "discovered" : + "unattributed"; + + // Books and treasury balances run in parallel + const [booksResult, ...balanceResults] = await Promise.allSettled([ + buildAgentBooks(agent, period), + ...eligibleWallets.map((w) => getWalletStableBalance(w.address)), + ]); + + const books = booksResult.status === "fulfilled" ? booksResult.value : null; + + const treasuryWallets = eligibleWallets.map((w, i) => { + const balance = balanceResults[i]?.status === "fulfilled" ? (balanceResults[i] as PromiseFulfilledResult).value : 0; + return { + address: w.address.toLowerCase(), + label: w.label, + role: w.role ?? null, + address_type: w.address_type ?? "unknown", + stable_balance_usd: balance, + chain: w.chain ?? "base", + }; + }); + + const totalBalance = Math.round( + treasuryWallets.reduce((s, w) => s + w.stable_balance_usd, 0) * 100, + ) / 100; + + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ + skill: "luca-report", + agent: { + name: agent.name, + slug: toSlug(agent.name), + ecosystem: agent.ecosystem, + verification_status: agent.verificationStatus, + evidence_sources: agent.evidenceSources ?? [], + erc8004_agent_id: agent.erc8004AgentId ?? null, + erc8004_did: agent.erc8004Did ?? null, + website: agent.website ?? null, + x_handle: agent.xHandle ?? null, + }, + attribution: { + tier: attributionTier, + books_eligible_wallets: eligibleWallets.length, + total_wallets: allWallets.length, + source: eligibleWallets.length > 0 ? "manifest" : null, + }, + books, + treasury: { + wallets: treasuryWallets, + total_stable_balance_usd: totalBalance, + health: treasuryHealth(totalBalance, eligibleWallets.length > 0), + }, + summary: books?.attributed ? books.luca_summary : null, + generated_at: new Date().toISOString(), + }); + } catch (error) { + auth.finish(500, Date.now() - start, endpoint); + return ledgerErrorResponse(error); + } +} diff --git a/src/app/api/luca/skills/registry-check/route.ts b/src/app/api/luca/skills/registry-check/route.ts new file mode 100644 index 0000000..7866c7d --- /dev/null +++ b/src/app/api/luca/skills/registry-check/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { v1Auth } from "@/lib/v1-auth"; +import { getRegistryAgents } from "@/lib/registry-db"; +import { isBooksEligibleWallet } from "@/lib/wallet-eligibility"; +import { toSlug } from "@/app/registry/[slug]/slug"; + +export const dynamic = "force-dynamic"; + +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/i; + +export async function POST(req: NextRequest) { + const auth = await v1Auth(req); + if (!auth.ok) return auth.response; + + const start = Date.now(); + const endpoint = "/api/luca/skills/registry-check"; + + let body: { query?: string }; + try { + body = await req.json() as { query?: string }; + } catch { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const query = (body.query ?? "").trim(); + if (!query) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "query is required (agent slug, name, or 0x wallet address)" }, { status: 400 }); + } + + try { + const { agents } = await getRegistryAgents(); + const isAddress = ADDRESS_RE.test(query); + const queryLower = query.toLowerCase(); + + let matchType: "slug" | "name" | "wallet_address" | null = null; + type Agent = (typeof agents)[0]; + let match: Agent | undefined; + + if (isAddress) { + match = agents.find((a) => + (a.wallets ?? []).some((w) => w.address.toLowerCase() === queryLower), + ); + if (match) matchType = "wallet_address"; + } + + if (!match) { + match = agents.find((a) => toSlug(a.name) === queryLower); + if (match) matchType = "slug"; + } + + if (!match) { + match = agents.find((a) => a.name.toLowerCase().includes(queryLower)); + if (match) matchType = "name"; + } + + if (!match) { + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ + skill: "registry-check", + found: false, + query, + agent: null, + generated_at: new Date().toISOString(), + }); + } + + const wallets = match.wallets ?? []; + const eligibleWallets = wallets.filter((w) => isBooksEligibleWallet(w, match!.tokenAddress).eligible); + + const attributionTier = + eligibleWallets.length > 0 ? "manifest_attributed" : + wallets.length > 0 ? "discovered" : + "unattributed"; + + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ + skill: "registry-check", + found: true, + query, + match_type: matchType, + agent: { + name: match.name, + slug: toSlug(match.name), + ecosystem: match.ecosystem, + verification_status: match.verificationStatus, + evidence_sources: match.evidenceSources ?? [], + erc8004_agent_id: match.erc8004AgentId ?? null, + erc8004_did: match.erc8004Did ?? null, + attribution_tier: attributionTier, + books_eligible_wallets: eligibleWallets.length, + total_wallets: wallets.length, + wallets: wallets.map((w) => { + const elig = isBooksEligibleWallet(w, match!.tokenAddress); + return { + address: w.address.toLowerCase(), + label: w.label, + role: w.role ?? null, + address_type: w.address_type ?? "unknown", + evidence_source: w.evidenceSource ?? null, + books_eligible: elig.eligible, + books_ineligibility_reason: elig.eligible ? null : elig.reason, + chain: w.chain ?? "base", + }; + }), + }, + generated_at: new Date().toISOString(), + }); + } catch (e) { + auth.finish(502, Date.now() - start, endpoint); + return NextResponse.json( + { error: e instanceof Error ? e.message : "Registry check failed" }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/luca/skills/revenue-analysis/route.ts b/src/app/api/luca/skills/revenue-analysis/route.ts new file mode 100644 index 0000000..dbb124b --- /dev/null +++ b/src/app/api/luca/skills/revenue-analysis/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; +import { v1Auth } from "@/lib/v1-auth"; +import { buildAgentBooks, getAgentBySlug } from "@/lib/agent-books"; +import { ledgerErrorResponse } from "@/lib/api-utils"; +import type { TimeRange } from "@/lib/ledger"; + +export const dynamic = "force-dynamic"; + +const VALID_PERIODS = new Set(["7d", "14d", "30d", "90d"]); + +export async function POST(req: NextRequest) { + const auth = await v1Auth(req); + if (!auth.ok) return auth.response; + + const start = Date.now(); + const endpoint = "/api/luca/skills/revenue-analysis"; + + let body: { slug?: string; period?: string }; + try { + body = await req.json() as { slug?: string; period?: string }; + } catch { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const slug = (body.slug ?? "").trim().toLowerCase(); + if (!slug) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "slug is required" }, { status: 400 }); + } + + const period = (body.period ?? "30d") as TimeRange; + if (!VALID_PERIODS.has(period)) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "period must be 7d, 14d, 30d, or 90d" }, { status: 400 }); + } + + try { + const agent = await getAgentBySlug(slug); + if (!agent) { + auth.finish(404, Date.now() - start, endpoint); + return NextResponse.json({ error: `Agent '${slug}' not found` }, { status: 404 }); + } + + const books = await buildAgentBooks(agent, period); + + if (!books.attributed) { + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ + skill: "revenue-analysis", + agent: books.agent, + period, + attributed: false, + reason: books.reason, + message: books.message ?? null, + }); + } + + const { financials, classification, confidence, breakdown } = books; + + const recognitionRate = financials.gross_inflow_usd > 0 + ? Math.round((financials.revenue_usd / financials.gross_inflow_usd) * 100) + : 0; + + const excludedPct = 100 - recognitionRate; + + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ + skill: "revenue-analysis", + agent: books.agent, + period, + attributed: true, + wallets: books.wallets, + revenue: { + gross_inflow_usd: financials.gross_inflow_usd, + operating_revenue_usd: financials.revenue_usd, + quarantined_inflows_usd: classification.quarantined_inflows_usd, + dex_excluded_usd: classification.dex_excluded_usd, + revenue_recognition_rate_pct: recognitionRate, + gross_inflow_is_not_revenue: true, + note: excludedPct > 0 + ? `${excludedPct}% of gross inflows excluded (capital injections, DEX activity, or internal transfers)` + : "All inflows recognized as operating revenue", + }, + expenses: { + total_usd: financials.expenses_usd, + bridge_excluded_usd: classification.bridge_excluded_expense_usd, + dex_excluded_usd: classification.dex_excluded_usd, + net_income_usd: financials.net_income_usd, + margin_pct: financials.margin_pct, + }, + confidence, + breakdown: { + revenue_by_source: breakdown.revenue_by_source, + expenses_by_category: breakdown.expenses_by_category, + quarantined_events: classification.quarantined_events, + }, + generated_at: books.generated_at, + }); + } catch (error) { + auth.finish(500, Date.now() - start, endpoint); + return ledgerErrorResponse(error); + } +} diff --git a/src/app/api/luca/skills/route.ts b/src/app/api/luca/skills/route.ts new file mode 100644 index 0000000..0dacef2 --- /dev/null +++ b/src/app/api/luca/skills/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +const SKILLS = [ + { + id: "wallet-audit", + name: "Wallet Audit", + description: "Classify an on-chain address and determine its books-eligibility type. Returns address_type (eoa, token_contract, treasury_contract, etc.) and whether it is compatible with books attribution.", + endpoint: "/api/luca/skills/wallet-audit", + method: "POST", + input: { + address: { type: "string", required: true, description: "0x Ethereum address to audit" }, + chain: { type: "string", required: false, default: "base", description: "Chain name (currently only 'base' is supported)" }, + }, + }, + { + id: "agent-books", + name: "Agent Books", + description: "Full financial statement for a registered agent: revenue, expenses, net income, wallet count, and confidence signals. Only manifest-declared eoa and treasury_contract wallets are counted.", + endpoint: "/api/luca/skills/agent-books", + method: "POST", + input: { + slug: { type: "string", required: true, description: "Agent slug (e.g. 'aeon', 'luca')" }, + period: { type: "string", required: false, default: "30d", enum: ["7d", "14d", "30d", "90d"] }, + }, + }, + { + id: "treasury-monitor", + name: "Treasury Monitor", + description: "Stablecoin balance and health of an agent's declared treasury wallets. Returns per-wallet USDC+USDT balances and an overall health signal (healthy/low/critical).", + endpoint: "/api/luca/skills/treasury-monitor", + method: "POST", + input: { + slug: { type: "string", required: false, description: "Agent slug. Required if address not provided." }, + address: { type: "string", required: false, description: "Single wallet address. Required if slug not provided." }, + }, + }, + { + id: "revenue-analysis", + name: "Revenue Analysis", + description: "Revenue breakdown with the critical gross_inflow vs operating_revenue distinction. Shows quarantine breakdown, revenue recognition rate, and per-source attribution. gross_inflow_usd is NOT revenue.", + endpoint: "/api/luca/skills/revenue-analysis", + method: "POST", + input: { + slug: { type: "string", required: true, description: "Agent slug" }, + period: { type: "string", required: false, default: "30d", enum: ["7d", "14d", "30d", "90d"] }, + }, + }, + { + id: "registry-check", + name: "Registry Check", + description: "Look up an agent by slug, name, or wallet address. Returns attribution tier, books-eligible wallet count, ERC-8004 identity if available, and full wallet list with eligibility status.", + endpoint: "/api/luca/skills/registry-check", + method: "POST", + input: { + query: { type: "string", required: true, description: "Agent slug, name fragment, or 0x wallet address" }, + }, + }, + { + id: "luca-report", + name: "Luca Report", + description: "Full composite report: registry identity, attribution tier, financial statement, treasury balance, and Luca's narrative summary. The single-call complete picture of an agent's financial identity.", + endpoint: "/api/luca/skills/luca-report", + method: "POST", + input: { + slug: { type: "string", required: true, description: "Agent slug" }, + period: { type: "string", required: false, default: "30d", enum: ["7d", "14d", "30d", "90d"] }, + }, + }, +]; + +export async function GET() { + return NextResponse.json({ + version: "1.0", + name: "Luca Skills", + description: "Callable financial intelligence for autonomous agents and builders. Each skill enforces strict data integrity rules.", + auth: "Authorization: Bearer or X-API-Key: ", + data_integrity: { + erc8004_role: "identity and discovery only — never financial", + attribution_rule: "manifest-declared wallets only (evidenceSource: manifest)", + books_eligible_types: ["eoa", "treasury_contract"], + excluded_types: ["token_contract", "smart_contract", "proxy_contract", "vault", "unknown"], + gross_inflow_warning: "gross_inflow_usd is NOT revenue — use revenue-analysis for the operating_revenue figure", + }, + skills: SKILLS, + generated_at: new Date().toISOString(), + }); +} diff --git a/src/app/api/luca/skills/treasury-monitor/route.ts b/src/app/api/luca/skills/treasury-monitor/route.ts new file mode 100644 index 0000000..6292c72 --- /dev/null +++ b/src/app/api/luca/skills/treasury-monitor/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from "next/server"; +import { v1Auth } from "@/lib/v1-auth"; +import { getAgentBySlug } from "@/lib/agent-books"; +import { classifyAddressBatch } from "@/lib/address-classifier"; +import { getWalletStableBalance } from "@/lib/treasury-balance"; +import { isBooksEligibleWallet } from "@/lib/wallet-eligibility"; + +export const dynamic = "force-dynamic"; + +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +function healthSignal(balance: number): "healthy" | "low" | "critical" { + if (balance >= 10_000) return "healthy"; + if (balance >= 1_000) return "low"; + return "critical"; +} + +export async function POST(req: NextRequest) { + const auth = await v1Auth(req); + if (!auth.ok) return auth.response; + + const start = Date.now(); + const endpoint = "/api/luca/skills/treasury-monitor"; + + let body: { slug?: string; address?: string }; + try { + body = await req.json() as { slug?: string; address?: string }; + } catch { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { slug, address } = body; + if (!slug && !address) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "slug or address is required" }, { status: 400 }); + } + + const apiKey = process.env.ALCHEMY_API_KEY; + if (!apiKey) { + auth.finish(500, Date.now() - start, endpoint); + return NextResponse.json({ error: "ALCHEMY_API_KEY not configured" }, { status: 500 }); + } + + try { + if (address && !slug) { + const addr = address.trim().toLowerCase(); + if (!ADDRESS_RE.test(addr)) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "address must be a valid 0x Ethereum address" }, { status: 400 }); + } + const [classified] = await classifyAddressBatch([addr], apiKey); + const balance = await getWalletStableBalance(addr); + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ + skill: "treasury-monitor", + source: "address", + agent: null, + wallets: [{ + address: addr, + address_type: classified.address_type, + stable_balance_usd: balance, + health: healthSignal(balance), + chain: "base", + }], + total_stable_balance_usd: balance, + health: healthSignal(balance), + generated_at: new Date().toISOString(), + }); + } + + const agent = await getAgentBySlug((slug ?? "").trim().toLowerCase()); + if (!agent) { + auth.finish(404, Date.now() - start, endpoint); + return NextResponse.json({ error: `Agent '${slug}' not found` }, { status: 404 }); + } + + const allWallets = agent.wallets ?? []; + const eligibleWallets = allWallets.filter((w) => isBooksEligibleWallet(w, agent.tokenAddress).eligible); + const targetWallets = eligibleWallets.length > 0 ? eligibleWallets : allWallets; + + if (targetWallets.length === 0) { + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ + skill: "treasury-monitor", + source: "slug", + agent: { name: agent.name, slug: (slug ?? "").trim().toLowerCase(), ecosystem: agent.ecosystem }, + wallets: [], + total_stable_balance_usd: 0, + health: "unknown", + note: "No wallets found for this agent. Submit a wallet manifest to enable treasury monitoring.", + generated_at: new Date().toISOString(), + }); + } + + const balances = await Promise.allSettled( + targetWallets.map((w) => getWalletStableBalance(w.address)), + ); + + let total = 0; + const walletRows = targetWallets.map((w, i) => { + const balance = balances[i].status === "fulfilled" ? balances[i].value : 0; + total += balance; + const elig = isBooksEligibleWallet(w, agent.tokenAddress); + return { + address: w.address.toLowerCase(), + label: w.label, + role: w.role ?? null, + address_type: w.address_type ?? "unknown", + evidence_source: w.evidenceSource ?? null, + books_eligible: elig.eligible, + books_ineligibility_reason: elig.eligible ? null : elig.reason, + stable_balance_usd: balance, + health: healthSignal(balance), + chain: w.chain ?? "base", + }; + }); + + const totalRounded = Math.round(total * 100) / 100; + + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ + skill: "treasury-monitor", + source: "slug", + agent: { + name: agent.name, + slug: (slug ?? "").trim().toLowerCase(), + ecosystem: agent.ecosystem, + }, + wallets: walletRows, + total_stable_balance_usd: totalRounded, + health: total === 0 ? "critical" : healthSignal(total), + note: eligibleWallets.length === 0 + ? "No books-eligible wallets found — showing all wallets. Declare manifest wallets to enable financial attribution." + : null, + generated_at: new Date().toISOString(), + }); + } catch (e) { + auth.finish(502, Date.now() - start, endpoint); + return NextResponse.json( + { error: e instanceof Error ? e.message : "Treasury monitor failed" }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/luca/skills/wallet-audit/route.ts b/src/app/api/luca/skills/wallet-audit/route.ts new file mode 100644 index 0000000..eb0443c --- /dev/null +++ b/src/app/api/luca/skills/wallet-audit/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { v1Auth } from "@/lib/v1-auth"; +import { classifyAddressBatch } from "@/lib/address-classifier"; +import { getWalletStableBalance } from "@/lib/treasury-balance"; +import { BOOKS_ELIGIBLE_ADDRESS_TYPES } from "@/lib/wallet-eligibility"; + +export const dynamic = "force-dynamic"; + +const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; + +export async function POST(req: NextRequest) { + const auth = await v1Auth(req); + if (!auth.ok) return auth.response; + + const start = Date.now(); + const endpoint = "/api/luca/skills/wallet-audit"; + + let body: { address?: string; chain?: string }; + try { + body = await req.json() as { address?: string; chain?: string }; + } catch { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const address = (body.address ?? "").trim(); + if (!ADDRESS_RE.test(address)) { + auth.finish(400, Date.now() - start, endpoint); + return NextResponse.json({ error: "address must be a valid 0x Ethereum address (42 chars)" }, { status: 400 }); + } + + const apiKey = process.env.ALCHEMY_API_KEY; + if (!apiKey) { + auth.finish(500, Date.now() - start, endpoint); + return NextResponse.json({ error: "ALCHEMY_API_KEY not configured" }, { status: 500 }); + } + + try { + const [classified] = await classifyAddressBatch([address], apiKey); + const addressType = classified.address_type; + const booksCompatible = BOOKS_ELIGIBLE_ADDRESS_TYPES.has(addressType); + + let stableBalanceUsd: number | null = null; + if (booksCompatible) { + stableBalanceUsd = await getWalletStableBalance(address); + } + + const notes: string[] = []; + if (booksCompatible) { + notes.push( + `${addressType} wallets are books-compatible — declare this address in your agent manifest (evidenceSource: manifest) to enable financial attribution`, + ); + } else { + notes.push(`${addressType} wallets are excluded from books regardless of manifest declaration`); + if (addressType === "token_contract") { + notes.push("Token contracts are excluded to prevent inflating revenue figures with token issuance events"); + } else if (addressType === "smart_contract" || addressType === "proxy_contract") { + notes.push("Only eoa and treasury_contract (Gnosis Safe) address types qualify for books attribution"); + } + } + + auth.finish(200, Date.now() - start, endpoint); + return NextResponse.json({ + skill: "wallet-audit", + address: address.toLowerCase(), + chain: body.chain ?? "base", + address_type: addressType, + books_compatible: booksCompatible, + books_compatible_note: booksCompatible + ? "This address type is eligible for books attribution when declared in the agent manifest" + : "This address type is excluded from books regardless of manifest declaration", + stable_balance_usd: stableBalanceUsd, + notes, + classified_at: new Date().toISOString(), + }); + } catch (e) { + auth.finish(502, Date.now() - start, endpoint); + return NextResponse.json( + { error: e instanceof Error ? e.message : "Address classification failed" }, + { status: 502 }, + ); + } +}