diff --git a/apps/skillauditor-api/src/index.ts b/apps/skillauditor-api/src/index.ts index 192d994..3bc2c83 100644 --- a/apps/skillauditor-api/src/index.ts +++ b/apps/skillauditor-api/src/index.ts @@ -16,6 +16,7 @@ import auditsRoute from './routes/v1/audits.js' import skillsRoute from './routes/v1/skills.js' import verifyRoute from './routes/v1/verify.js' import ledgerRoute from './routes/v1/ledger.js' +import ensRoute from './routes/v1/ens.js' // Routes — management (auth required) import usersRoute from './routes/management/users.js' @@ -64,6 +65,7 @@ app.use('/v1/*', generalRateLimit) app.route('/v1/skills', skillsRoute) app.route('/v1/verify', verifyRoute) app.route('/v1/audits', auditsRoute) +app.route('/v1/ens', ensRoute) // World ID gated + x402 Pro payment gate — auth handled inside the route app.use('/v1/submit', submitRateLimit) diff --git a/apps/skillauditor-api/src/routes/v1/ens.ts b/apps/skillauditor-api/src/routes/v1/ens.ts new file mode 100644 index 0000000..f5d4ba1 --- /dev/null +++ b/apps/skillauditor-api/src/routes/v1/ens.ts @@ -0,0 +1,91 @@ +import { Hono } from 'hono' +import { createPublicClient, http, keccak256, toBytes, concat, type Hex, type Address } from 'viem' +import { sepolia } from 'viem/chains' + +const ens = new Hono() + +const REGISTRAR_ADDRESS = (process.env.SKILL_SUBNAME_REGISTRAR_ADDRESS ?? '') as Address +const ETH_SEPOLIA_RPC = process.env.ETH_SEPOLIA_RPC_URL ?? 'https://ethereum-sepolia-rpc.publicnode.com' + +const RESOLVE_SKILL_ABI = [ + { + type: 'function', + name: 'resolveSkill', + inputs: [{ name: 'subnameNode', type: 'bytes32' }], + outputs: [ + { name: 'verdict', type: 'string' }, + { name: 'score', type: 'string' }, + { name: 'reportCid', type: 'string' }, + { name: 'auditedAt', type: 'string' }, + { name: 'auditor', type: 'string' }, + { name: 'skillName', type: 'string' }, + { name: 'skillHash', type: 'string' }, + { name: 'auditId', type: 'string' }, + { name: 'baseTxHash', type: 'string' }, + ], + stateMutability: 'view', + }, +] as const + +function namehash(name: string): Hex { + if (!name) return `0x${'00'.repeat(32)}` as Hex + const labels = name.split('.').reverse() + let node: Uint8Array = new Uint8Array(32) + for (const label of labels) { + const labelHash = keccak256(toBytes(label), 'bytes') + node = keccak256(concat([node, labelHash as unknown as Uint8Array]), 'bytes') as unknown as Uint8Array + } + return `0x${Buffer.from(node).toString('hex')}` as Hex +} + +// GET /v1/ens/resolve?name=github-pr-reviewer-7afc6af3.skills.skillauditor.eth +ens.get('/resolve', async (c) => { + const ensName = c.req.query('name')?.trim().toLowerCase() + if (!ensName) return c.json({ error: 'name query parameter is required' }, 400) + + if (!REGISTRAR_ADDRESS || REGISTRAR_ADDRESS === '0x') { + return c.json({ error: 'ENS registrar not configured on this server' }, 503) + } + + try { + const client = createPublicClient({ chain: sepolia, transport: http(ETH_SEPOLIA_RPC) }) + const node = namehash(ensName) + + const result = await client.readContract({ + address: REGISTRAR_ADDRESS, + abi: RESOLVE_SKILL_ABI, + functionName: 'resolveSkill', + args: [node], + }) as unknown as [string, string, string, string, string, string, string, string, string] + + const [verdict, score, reportCid, auditedAt, auditor, skillName, skillHash, auditId, baseTxHash] = result + + // Empty verdict means the name isn't registered + if (!verdict) return c.json({ error: 'ENS name not found or not yet registered' }, 404) + + return c.json({ + ensName, + verdict, + score: Number(score), + reportCid, + auditedAt: Number(auditedAt), + auditor, + skillName, + skillHash, + auditId, + baseTxHash, + links: { + audit: auditId ? `/v1/audits/${auditId}` : null, + baseScan: baseTxHash ? `https://sepolia.basescan.org/tx/${baseTxHash}` : null, + etherscan: `https://sepolia.etherscan.io/address/${REGISTRAR_ADDRESS}`, + ensApp: `https://app.ens.domains/${ensName}?chain=sepolia`, + }, + }) + } catch (err) { + const message = (err as Error).message ?? 'Unknown error' + console.error('[ens/resolve]', ensName, message) + return c.json({ error: 'Failed to resolve ENS name', detail: message }, 500) + } +}) + +export default ens diff --git a/apps/skillauditor-app/app/ens/[name]/page.tsx b/apps/skillauditor-app/app/ens/[name]/page.tsx new file mode 100644 index 0000000..7a1bb7c --- /dev/null +++ b/apps/skillauditor-app/app/ens/[name]/page.tsx @@ -0,0 +1,179 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' + +export const dynamic = 'force-dynamic' + +interface ENSRecord { + ensName: string + verdict: 'safe' | 'review_required' | 'unsafe' + score: number + reportCid: string + auditedAt: number + auditor: string + skillName: string + skillHash: string + auditId: string + baseTxHash: string + links: { + audit: string | null + baseScan: string | null + etherscan: string + ensApp: string + } +} + +async function resolveENS(ensName: string): Promise { + const apiBase = process.env.API_URL ?? 'http://localhost:3001' + try { + const res = await fetch(`${apiBase}/v1/ens/resolve?name=${encodeURIComponent(ensName)}`) + if (res.status === 404) return null + if (!res.ok) return null + return res.json() as Promise + } catch { + return null + } +} + +function VerdictBadge({ verdict }: { verdict: ENSRecord['verdict'] }) { + const cfg = { + safe: { label: 'Safe', dot: 'bg-green-500', cls: 'bg-green-50 border-green-200 text-green-700' }, + review_required: { label: 'Review Required', dot: 'bg-amber-500', cls: 'bg-amber-50 border-amber-200 text-amber-700' }, + unsafe: { label: 'Unsafe', dot: 'bg-red-500', cls: 'bg-red-50 border-red-200 text-red-700' }, + }[verdict] + return ( + + + {cfg.label} + + ) +} + +function Row({ label, value, mono, href }: { label: string; value: string; mono?: boolean; href?: string }) { + return ( +
+ {label} + {href ? ( + + {value} + + ) : ( + {value} + )} +
+ ) +} + +export default async function ENSResolvePage({ params }: { params: Promise<{ name: string }> }) { + const { name } = await params + const ensName = decodeURIComponent(name).toLowerCase() + const record = await resolveENS(ensName) + + if (!record) notFound() + + const auditedDate = record.auditedAt + ? new Date(record.auditedAt * 1000).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' + : null + + const castCommand = `cast call \\ + 0xd68f99d601155e7ca79327010dfd2636e6157b5f \\ + "resolveSkill(bytes32)(string,string,string,string,string,string,string,string,string)" \\ + $(cast namehash "${ensName}") \\ + --rpc-url https://ethereum-sepolia-rpc.publicnode.com` + + return ( +
+
+ SkillAuditor + / + ENS Lookup +
+ +
+ + {/* Name + verified badge */} +
+
+ + + + + + ENS Verified + + skills.skillauditor.eth +
+

{ensName}

+ {record.skillName && ( +

{record.skillName}

+ )} +
+ + {/* Verdict + Score */} +
+
+

Verdict

+ +
+
+
+

Safety Score

+

= 80 ? 'text-green-600' : record.score >= 60 ? 'text-amber-600' : 'text-red-600'}`}> + {record.score}/100 +

+
+
+ + {/* Audit records */} +
+
+

Onchain Records

+
+
+ {record.skillName && } + {record.skillHash && } + {record.auditId && } + {record.baseTxHash && } + {record.auditor && } + {record.reportCid && } + {auditedDate && } +
+
+ + {/* Links */} +
+ {record.links.audit && ( + + View Full Audit → + + )} + {record.links.baseScan && ( + + Base Sepolia Stamp → + + )} + + Registrar Contract → + +
+ + {/* "Resolve it yourself" code block — the agent demo */} +
+
+

Resolve Without This API

+ Any Ethereum RPC · No API key needed +
+
+            {castCommand}
+          
+
+ +
+
+ ) +}