diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b279abc63..9240190f8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -135,6 +135,9 @@ jobs: - name: Test manifest rendering run: npm run test:render + - name: Test Testnet ENSv2 metadata adapter + run: node --test adapter/server.test.mjs + - name: Render test manifest run: npm run render:test diff --git a/README.md b/README.md index e6d2cd890..5b7c43310 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,18 @@ ## Monorepo for ENSv2 contracts + chain Currently there is only one module, [contracts](/contracts). Read the contracts README to see how to build/test ENSv2 contracts. + +## Testnet BENS metadata adapter + +`subgraph/dos-names/adapter/server.mjs` is a read-only compatibility proxy for +the official BENS ENS-like API on DOS Testnet (3939). It preserves ENS namehash +identities while taking ERC-1155 token IDs and registry addresses from the +canonical Graph Node domain record. Public explorer links use the Testnet +origin; BENS itself can continue using its internal Blockscout URL for history. + +The runtime loads the pinned deployment manifest from `/app/deployment.json`, +forwards public reads to `bens:8050`, and queries `bens-graph-node:8000` for token +metadata. It fails closed on indexing errors or inconsistent token state. It +has bounded requests, does not forward credentials, holds no keys and cannot +sign or submit transactions. Mainnet is not enabled. Run its isolated tests +with `node --test subgraph/dos-names/adapter/server.test.mjs` from the repo root. diff --git a/subgraph/dos-names/adapter/server.mjs b/subgraph/dos-names/adapter/server.mjs new file mode 100644 index 000000000..a313fdc11 --- /dev/null +++ b/subgraph/dos-names/adapter/server.mjs @@ -0,0 +1,225 @@ +import { readFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { pathToFileURL } from "node:url"; + +const MAX_BYTES = 2 * 1024 * 1024; +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const DOMAIN_ID = /^0x[0-9a-fA-F]{64}$/; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; +const MAX_UINT256 = (1n << 256n) - 1n; + +export function validateDeployment(deployment) { + const registry = deployment?.contracts?.dosRegistry; + if ( + deployment?.chainId !== 3939 || + !ADDRESS.test(registry ?? "") || + /^0x0{40}$/i.test(registry) + ) { + throw new Error("A nonzero Testnet 3939 registry is required"); + } + return registry; +} + +export async function rewriteMetadata(value, config, lookup) { + let lookups = 0; + async function walk(item, depth = 0) { + if (depth > 16) throw new Error("Metadata nesting exceeds the limit"); + if (Array.isArray(item)) + return Promise.all(item.map((entry) => walk(entry, depth + 1))); + if (item === null || typeof item !== "object") return item; + const result = Object.fromEntries( + await Promise.all( + Object.entries(item).map(async ([key, entry]) => [ + key, + await walk(entry, depth + 1), + ]), + ), + ); + if (item.protocol?.id === "dos-names") { + result.protocol.deployment_blockscout_base_url = `${config.publicOrigin}/`; + if (Array.isArray(item.tokens) && item.tokens.length) { + if (!DOMAIN_ID.test(item.id ?? "") || ++lookups > 50) + throw new Error("Invalid domain metadata"); + const indexed = await lookup(item.id); + if ( + indexed?.id?.toLowerCase() !== item.id.toLowerCase() || + indexed?.name !== item.name + ) + throw new Error("Canonical ENSv2 domain identity is unavailable"); + if ( + indexed.tokenId === null && + indexed.owner?.id?.toLowerCase() === ZERO_ADDRESS && + indexed.wrappedOwner?.id?.toLowerCase() === ZERO_ADDRESS + ) { + result.tokens = []; + return result; + } + if ( + typeof indexed?.tokenId !== "string" || + !/^[0-9]{1,78}$/.test(indexed?.tokenId ?? "") || + BigInt(indexed.tokenId) === 0n || + BigInt(indexed.tokenId) > MAX_UINT256 + ) + throw new Error("Canonical ENSv2 token state is unavailable"); + result.tokens = item.tokens.map((token) => { + if ( + token.type !== "WRAPPED_DOMAIN_TOKEN" || + !ADDRESS.test(indexed.owner?.id ?? "") || + token.contract_hash?.toLowerCase() !== + indexed.owner.id.toLowerCase() + ) { + throw new Error( + "Token contract does not match the canonical subgraph registry", + ); + } + return { ...token, id: indexed.tokenId }; + }); + } + } + if (item.id === "dos-names" && "deployment_blockscout_base_url" in item) { + result.deployment_blockscout_base_url = `${config.publicOrigin}/`; + } + return result; + } + return walk(value); +} + +async function readLimited(stream) { + const chunks = []; + let length = 0; + for await (const chunk of stream ?? []) { + length += chunk.length; + if (length > MAX_BYTES) throw new Error("Payload exceeds the limit"); + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} + +export function createAdapter(config) { + const upstream = new URL(config.upstreamUrl); + const graph = new URL(config.graphqlUrl); + if ( + !ADDRESS.test(config.registry) || + config.publicOrigin !== "https://test.doscan.io" + ) { + throw new Error("Adapter must target the public Testnet explorer"); + } + let active = 0; + const server = createServer( + { maxHeaderSize: 16 * 1024 }, + async (req, res) => { + if (!["GET", "POST"].includes(req.method)) { + res.writeHead(405, { allow: "GET, POST" }); + return res.end(); + } + if (active >= 16) { + res.writeHead(503); + return res.end(); + } + active++; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + const disconnected = () => { + if (!res.writableFinished) controller.abort(); + }; + res.once("close", disconnected); + req.once("aborted", disconnected); + try { + if (!req.url?.startsWith("/") || req.url.startsWith("//")) + throw new Error("Invalid path"); + const target = new URL(req.url, upstream); + if (target.origin !== upstream.origin) + throw new Error("Upstream origin mismatch"); + const signal = controller.signal; + const body = req.method === "POST" ? await readLimited(req) : undefined; + const response = await fetch(target, { + method: req.method, + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body, + signal, + redirect: "manual", + }); + let bytes = await readLimited(response.body); + if ( + response.ok && + response.headers.get("content-type")?.includes("application/json") + ) { + const lookup = async (id) => { + const graphResponse = await fetch(graph, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + query: + "query Token($id: ID!) { domain(id: $id) { id name tokenId owner { id } wrappedOwner { id } } _meta { hasIndexingErrors } }", + variables: { id }, + }), + signal, + redirect: "error", + }); + if (!graphResponse.ok) throw new Error("Graph Node is unavailable"); + const result = JSON.parse( + (await readLimited(graphResponse.body)).toString("utf8"), + ); + if ( + result.errors?.length || + result.data?._meta?.hasIndexingErrors !== false + ) + throw new Error("Subgraph indexing is unavailable"); + return result.data.domain; + }; + const metadata = await rewriteMetadata( + JSON.parse(bytes.toString("utf8")), + config, + lookup, + ); + bytes = Buffer.from(JSON.stringify(metadata)); + } + for (const header of ["content-type", "cache-control", "retry-after"]) { + const value = response.headers.get(header); + if (value) res.setHeader(header, value); + } + res.setHeader("content-length", bytes.length); + res.writeHead(response.status); + res.end(bytes); + } catch { + res.writeHead(502, { + "content-type": "application/json", + "cache-control": "no-store", + }); + res.end('{"message":"ENSv2 metadata is temporarily unavailable"}'); + } finally { + clearTimeout(timer); + res.off("close", disconnected); + req.off("aborted", disconnected); + active--; + } + }, + ); + server.requestTimeout = 15_000; + server.headersTimeout = 10_000; + return server; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + const deployment = JSON.parse( + readFileSync( + process.env.DOS_NAMES_DEPLOYMENT ?? "/app/deployment.json", + "utf8", + ), + ); + const registry = validateDeployment(deployment); + createAdapter({ + registry, + publicOrigin: "https://test.doscan.io", + upstreamUrl: "http://bens:8050", + graphqlUrl: "http://bens-graph-node:8000/subgraphs/name/dos-names", + }).listen(8050, "0.0.0.0", () => + console.log("DOS Names Testnet metadata adapter listening"), + ); +} diff --git a/subgraph/dos-names/adapter/server.test.mjs b/subgraph/dos-names/adapter/server.test.mjs new file mode 100644 index 000000000..c08abd04b --- /dev/null +++ b/subgraph/dos-names/adapter/server.test.mjs @@ -0,0 +1,261 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { test } from "node:test"; +import { + createAdapter, + rewriteMetadata, + validateDeployment, +} from "./server.mjs"; + +const registry = "0x95366f1E44532F50c022aEefF708F424b7854173"; +const node = + "0x9726493dec81dd17125aebf46d9e1093f970402e79c96bb24d723095a247acf5"; +const tokenId = + "109853185306708996857436761598381125367517663046059027338278467513256125661184"; +const config = { registry, publicOrigin: "https://test.doscan.io" }; +const domain = () => ({ + id: node, + name: "bens-smoke.dos", + tokens: [ + { + id: "68366885821481989290125917096282513121619573263602720883098992233964231961845", + contract_hash: registry, + type: "WRAPPED_DOMAIN_TOKEN", + }, + ], + protocol: { + id: "dos-names", + deployment_blockscout_base_url: "http://backend:4000/", + }, + owner: { hash: registry }, + resolved_address: { hash: "0x99999e454138f6be73E2bE82c890bc5765749999" }, +}); +const graphDomain = () => ({ + id: node, + name: "bens-smoke.dos", + tokenId, + owner: { id: registry }, +}); + +test("uses the indexed ENSv2 token ID without replacing the canonical namehash", async () => { + const result = await rewriteMetadata(domain(), config, async () => + graphDomain(), + ); + assert.equal(result.tokens[0].id, tokenId); + assert.equal(result.id, node); + assert.equal(result.tokens[0].contract_hash, registry); + assert.equal( + result.protocol.deployment_blockscout_base_url, + "https://test.doscan.io/", + ); +}); + +test("rewrites nested address lookup metadata and leaves unrelated protocols unchanged", async () => { + const other = { + protocol: { + id: "other", + deployment_blockscout_base_url: "https://other.example/", + }, + }; + const result = await rewriteMetadata( + { domain: domain(), other }, + config, + async () => graphDomain(), + ); + assert.equal(result.domain.tokens[0].id, tokenId); + assert.deepEqual(result.other, other); +}); + +test("fails closed for missing, mismatched, or invalid token state", async () => { + for (const state of [ + null, + { ...graphDomain(), id: `0x${"11".repeat(32)}` }, + { ...graphDomain(), name: "other.dos" }, + { ...graphDomain(), tokenId: null }, + { ...graphDomain(), tokenId: "1e30" }, + { ...graphDomain(), tokenId: (1n << 256n).toString() }, + ]) { + await assert.rejects(rewriteMetadata(domain(), config, async () => state)); + } +}); + +test("rejects token contracts that disagree with the canonical subgraph registry", async () => { + const value = domain(); + value.tokens[0].contract_hash = "0x1111111111111111111111111111111111111111"; + await assert.rejects( + rewriteMetadata(value, config, async () => graphDomain()), + ); +}); + +test("rejects numeric token IDs rather than accepting rounded JSON numbers", async () => { + await assert.rejects( + rewriteMetadata(domain(), config, async () => ({ + ...graphDomain(), + tokenId: 123, + })), + ); +}); + +test("unregistered and detached names retain metadata without a nonexistent NFT", async () => { + for (const name of ["bens-smoke.dos", "child.bens-smoke.dos"]) { + const value = { ...domain(), name }; + const removed = { + ...graphDomain(), + name, + tokenId: null, + owner: { id: "0x0000000000000000000000000000000000000000" }, + wrappedOwner: { id: "0x0000000000000000000000000000000000000000" }, + }; + const result = await rewriteMetadata(value, config, async () => removed); + assert.deepEqual(result.tokens, []); + assert.equal(result.name, name); + } +}); + +test("preserves a subdomain registry and reads regenerated token IDs without a stale cache", async () => { + const child = "0x1111111111111111111111111111111111111111"; + const value = domain(); + value.name = "child.bens-smoke.dos"; + value.tokens[0].contract_hash = child; + let version = "123"; + const lookup = async () => ({ + ...graphDomain(), + name: value.name, + tokenId: version, + owner: { id: child }, + }); + assert.equal( + (await rewriteMetadata(value, config, lookup)).tokens[0].id, + "123", + ); + version = "456"; + assert.equal( + (await rewriteMetadata(value, config, lookup)).tokens[0].id, + "456", + ); +}); + +test("rejects non-Testnet or incomplete deployment manifests", () => { + assert.throws(() => + validateDeployment({ chainId: 7979, contracts: { dosRegistry: registry } }), + ); + assert.throws(() => validateDeployment({ chainId: 3939, contracts: {} })); + assert.equal( + validateDeployment({ chainId: 3939, contracts: { dosRegistry: registry } }), + registry, + ); +}); + +async function listen(server) { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return `http://127.0.0.1:${server.address().port}`; +} + +test("HTTP adapter fixes NFT metadata, strips credentials, and preserves status semantics", async (t) => { + let graphUnavailable = false; + const upstream = createServer((req, res) => { + assert.equal(req.headers.authorization, undefined); + assert.equal(req.headers.cookie, undefined); + res.setHeader("content-type", "application/json"); + if (req.url === "/health") return res.end('{"status":"SERVING"}'); + if (req.url === "/missing") { + res.statusCode = 404; + return res.end('{"message":"Not found"}'); + } + res.setHeader("etag", '"upstream-etag"'); + res.end(JSON.stringify(domain())); + }); + const graph = createServer(async (req, res) => { + let body = ""; + for await (const chunk of req) body += chunk; + assert.equal(JSON.parse(body).variables.id, node); + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify( + graphUnavailable + ? { errors: [{ message: "unavailable" }] } + : { + data: { + domain: graphDomain(), + _meta: { hasIndexingErrors: false }, + }, + }, + ), + ); + }); + const upstreamUrl = await listen(upstream); + const graphqlUrl = await listen(graph); + const adapter = createAdapter({ ...config, upstreamUrl, graphqlUrl }); + const url = await listen(adapter); + t.after(async () => { + for (const server of [adapter, graph, upstream]) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } + }); + const response = await fetch(`${url}/api/v1/3939/domains/bens-smoke.dos`, { + headers: { authorization: "test-fixture", cookie: "test-fixture" }, + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("etag"), null); + assert.equal((await response.json()).tokens[0].id, tokenId); + assert.equal((await fetch(`${url}/missing`)).status, 404); + assert.deepEqual(await (await fetch(`${url}/health`)).json(), { + status: "SERVING", + }); + graphUnavailable = true; + assert.equal( + (await fetch(`${url}/api/v1/3939/domains/bens-smoke.dos`)).status, + 502, + ); + assert.equal( + (await fetch(`${url}/health`, { method: "DELETE" })).status, + 405, + ); +}); + +test("client disconnect cancels in-flight upstream work before the deadline", async (t) => { + let markStarted, markClosed; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const closed = new Promise((resolve) => { + markClosed = resolve; + }); + const upstream = createServer((_req, res) => { + res.once("close", markClosed); + markStarted(); + }); + const upstreamUrl = await listen(upstream); + const adapter = createAdapter({ + ...config, + upstreamUrl, + graphqlUrl: upstreamUrl, + }); + const url = await listen(adapter); + t.after(async () => { + for (const server of [adapter, upstream]) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } + }); + const controller = new AbortController(); + const response = fetch(`${url}/health`, { signal: controller.signal }); + await started; + controller.abort(); + await assert.rejects(response); + let timer; + try { + await Promise.race([ + closed, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("Upstream work was not cancelled")), + 1000, + ); + }), + ]); + } finally { + clearTimeout(timer); + } +});