feat: bridge chain artifacts to typed web reads

This commit is contained in:
golem
2026-08-21 03:11:24 -06:00
parent 54c893fdb7
commit ba393bbbc3
12 changed files with 749 additions and 1 deletions
+71
View File
@@ -0,0 +1,71 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const requiredFields = ["schemaVersion", "network", "chainId", "deploymentBlock", "token", "proxy", "implementation", "owner", "actors"];
const optionalFields = ["rpcUrl", "explorerBaseUrl"];
const zeroAddress = "0x0000000000000000000000000000000000000000";
const secretMarker = /(?:private[_ -]?key|mnemonic|secret|password|credential|api[_ -]?key)|0x[a-fA-F0-9]{64}/i;
export async function publishManifest({
activePath = resolve(repositoryRoot, "deployments/active.json"),
outputPath = resolve(repositoryRoot, "web/public/deployment.json"),
} = {}) {
let contents;
try { contents = await readFile(activePath, "utf8"); } catch { throw new Error(`active manifest is missing at ${activePath}`); }
let manifest;
try { manifest = JSON.parse(contents); } catch { throw new Error("active manifest contains invalid JSON"); }
validatePublicManifest(manifest);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(manifest, null, 2)}\n`);
return manifest;
}
export function validatePublicManifest(manifest) {
if (!isRecord(manifest)) throw new Error("manifest must be an object");
for (const field of requiredFields) if (!Object.hasOwn(manifest, field)) throw new Error(`manifest is missing required field ${field}`);
for (const field of Object.keys(manifest)) if (!requiredFields.includes(field) && !optionalFields.includes(field)) throw new Error(`manifest contains unknown field ${field}`);
rejectSecrets(manifest);
if (manifest.schemaVersion !== 1) throw new Error("manifest schemaVersion must be 1");
if (manifest.network !== "anvil" && manifest.network !== "baseSepolia") throw new Error("manifest network is unsupported");
if (manifest.chainId !== (manifest.network === "anvil" ? 31337 : 84532)) throw new Error("manifest chainId does not match network");
if (!Number.isSafeInteger(manifest.deploymentBlock) || manifest.deploymentBlock < 1) throw new Error("manifest deploymentBlock must be at least 1");
for (const field of ["token", "proxy", "implementation", "owner"]) assertAddress(manifest[field], field);
assertActors(manifest.actors);
for (const field of optionalFields) if (Object.hasOwn(manifest, field)) assertPublicUrl(manifest[field], field);
}
function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
function assertAddress(value, field) {
if (typeof value !== "string" || !/^0x[0-9a-fA-F]{40}$/.test(value) || value.toLowerCase() === zeroAddress) throw new Error(`manifest ${field} must be a nonzero address`);
}
function assertActors(value) {
if (!Array.isArray(value) || value.length === 0) throw new Error("manifest actors must be a nonempty array");
const labels = new Set(); const addresses = new Set();
for (const [index, actor] of value.entries()) {
if (!isRecord(actor) || Object.keys(actor).length !== 2 || !Object.hasOwn(actor, "label") || !Object.hasOwn(actor, "address")) throw new Error(`manifest actors[${index}] must contain label and address`);
if (typeof actor.label !== "string" || actor.label.trim() === "" || labels.has(actor.label)) throw new Error("manifest actor labels must be unique nonempty strings");
assertAddress(actor.address, `actors[${index}].address`);
const normalized = actor.address.toLowerCase();
if (addresses.has(normalized)) throw new Error("manifest actors must be unique");
labels.add(actor.label); addresses.add(normalized);
}
}
function assertPublicUrl(value, field) {
if (typeof value !== "string") throw new Error(`manifest ${field} must be a public URL`);
let url; try { url = new URL(value); } catch { throw new Error(`manifest ${field} must be a public URL`); }
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) throw new Error(`manifest ${field} must be a public URL without credentials`);
for (const key of url.searchParams.keys()) if (/(key|token|secret)/i.test(key)) throw new Error(`manifest ${field} must not contain credential query parameters`);
}
function rejectSecrets(value, path = "") {
if (typeof value === "string") { if (secretMarker.test(value)) throw new Error(`manifest contains prohibited secret material at ${path}`); return; }
if (Array.isArray(value)) { value.forEach((item, index) => rejectSecrets(item, `${path}[${index}]`)); return; }
if (!isRecord(value)) return;
for (const [key, nested] of Object.entries(value)) { rejectSecrets(key, path); rejectSecrets(nested, path ? `${path}.${key}` : key); }
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
if (process.argv.length !== 2) { console.error("usage: publish-web-manifest.mjs"); process.exitCode = 1; }
else publishManifest().catch((error) => { console.error(error.message); process.exitCode = 1; });
}
+68
View File
@@ -0,0 +1,68 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bankRequirements = {
functions: ["contractVersion", "paused", "asset", "owner", "totalLiabilities", "balanceOf"],
events: ["Deposited", "Withdrawn", "Paused", "Unpaused", "OwnershipTransferred", "Upgraded"],
};
const tokenRequirements = { functions: ["balanceOf"], events: [] };
export function extractAbi(artifact, contractName) {
if (!artifact || typeof artifact !== "object" || !Array.isArray(artifact.abi)) {
throw new Error(`${contractName} artifact must contain an ABI`);
}
const requirements = contractName === "BankV1" ? bankRequirements : contractName === "MockUSDC" ? tokenRequirements : null;
if (!requirements) throw new Error(`unsupported contract artifact ${contractName}`);
for (const [type, names] of Object.entries(requirements)) {
for (const name of names) {
if (!artifact.abi.some((entry) => entry && entry.type === type.slice(0, -1) && entry.name === name)) {
throw new Error(`${contractName} ABI is missing required ${type.slice(0, -1)} ${name}`);
}
}
}
return artifact.abi;
}
export function renderContractsModule(bankV1Abi, mockUsdcAbi) {
return `/* This file is generated by tools/sync-web-artifacts.mjs. Do not edit. */\n\nexport const bankV1Abi = ${JSON.stringify(bankV1Abi, null, 2)} as const;\n\nexport const mockUsdcAbi = ${JSON.stringify(mockUsdcAbi, null, 2)} as const;\n`;
}
export async function syncArtifacts({
bankArtifactPath = resolve(repositoryRoot, "out/BankV1.sol/BankV1.json"),
tokenArtifactPath = resolve(repositoryRoot, "out/MockUSDC.sol/MockUSDC.json"),
outputPath = resolve(repositoryRoot, "web/src/generated/contracts.ts"),
check = false,
} = {}) {
const [bankArtifact, tokenArtifact] = await Promise.all([
readArtifact(bankArtifactPath, "BankV1"),
readArtifact(tokenArtifactPath, "MockUSDC"),
]);
const contents = renderContractsModule(extractAbi(bankArtifact, "BankV1"), extractAbi(tokenArtifact, "MockUSDC"));
if (check) {
let existing;
try { existing = await readFile(outputPath, "utf8"); } catch { throw new Error("generated contracts module is stale or missing"); }
if (existing !== contents) throw new Error("generated contracts module is stale");
return contents;
}
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, contents);
return contents;
}
async function readArtifact(path, contractName) {
let contents;
try { contents = await readFile(path, "utf8"); } catch { throw new Error(`${contractName} artifact is missing at ${path}`); }
try { return JSON.parse(contents); } catch { throw new Error(`${contractName} artifact contains invalid JSON`); }
}
if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
const check = process.argv.slice(2).every((argument) => argument === "--check") && process.argv.includes("--check");
if (process.argv.slice(2).some((argument) => argument !== "--check")) {
console.error("usage: sync-web-artifacts.mjs [--check]");
process.exitCode = 1;
} else {
syncArtifacts({ check }).catch((error) => { console.error(error.message); process.exitCode = 1; });
}
}
+76
View File
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { extractAbi, renderContractsModule, syncArtifacts } from "./sync-web-artifacts.mjs";
import { publishManifest } from "./publish-web-manifest.mjs";
const address = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const bankAbi = [
...["contractVersion", "paused", "asset", "owner", "totalLiabilities", "balanceOf"].map((name) => ({ type: "function", name, inputs: [], outputs: [], stateMutability: "view" })),
...["Deposited", "Withdrawn", "Paused", "Unpaused", "OwnershipTransferred", "Upgraded"].map((name) => ({ type: "event", name, inputs: [], anonymous: false })),
];
const tokenAbi = [{ type: "function", name: "balanceOf", inputs: [{ name: "account", type: "address" }], outputs: [{ type: "uint256" }], stateMutability: "view" }];
const manifest = {
schemaVersion: 1, network: "anvil", chainId: 31337, deploymentBlock: 3,
rpcUrl: "http://127.0.0.1:8545", token: address, proxy: address,
implementation: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", owner,
actors: [{ label: "owner", address: owner }],
};
async function withFixture(run) {
const root = await mkdtemp(join(tmpdir(), "uups-artifacts-"));
try { await run(root); } finally { await rm(root, { recursive: true, force: true }); }
}
async function expectRejects(action, pattern) {
await assert.rejects(action, pattern);
}
await withFixture(async (root) => {
const bank = join(root, "BankV1.json");
const token = join(root, "MockUSDC.json");
const output = join(root, "contracts.ts");
// Catches a production bridge that silently produces an ABI module from incomplete artifacts.
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /BankV1 artifact/i);
await writeFile(bank, JSON.stringify({ abi: bankAbi }));
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output }), /MockUSDC artifact/i);
await writeFile(token, JSON.stringify({ abi: tokenAbi }));
// Catches a bridge that exports an ABI missing a V1 contract function or event.
assert.throws(() => extractAbi({ abi: bankAbi.filter((entry) => entry.name !== "Withdrawn") }, "BankV1"), /Withdrawn/);
const rendered = renderContractsModule(extractAbi({ abi: bankAbi }, "BankV1"), extractAbi({ abi: tokenAbi }, "MockUSDC"));
assert.match(rendered, /export const bankV1Abi = .* as const;/s);
assert.match(rendered, /export const mockUsdcAbi = .* as const;/s);
assert.doesNotMatch(rendered, /bankV2Abi|BankV2/);
// Catches a bridge that requires an active manifest or reads V2 as part of ABI generation.
await syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output });
assert.equal(await readFile(output, "utf8"), rendered);
await writeFile(output, "stale\n");
await expectRejects(() => syncArtifacts({ bankArtifactPath: bank, tokenArtifactPath: token, outputPath: output, check: true }), /stale/i);
});
await withFixture(async (root) => {
const activePath = join(root, "active.json");
const outputPath = join(root, "deployment.json");
await writeFile(activePath, JSON.stringify(manifest));
// Catches a publisher that copies pending, secret-bearing, or malformed live state into the browser bundle.
await publishManifest({ activePath, outputPath });
assert.deepEqual(JSON.parse(await readFile(outputPath, "utf8")), manifest);
for (const invalid of [
{ ...manifest, deploymentBlock: 0 },
{ ...manifest, rpcUrl: "https://token@example.test" },
{ ...manifest, rpcUrl: "https://example.test/?secret=value" },
{ ...manifest, privateKey: "not-public" },
{ ...manifest, proxy: "0x0000000000000000000000000000000000000000" },
]) {
await writeFile(activePath, JSON.stringify(invalid));
await expectRejects(() => publishManifest({ activePath, outputPath }), /manifest|deployment|credential|secret|address/i);
}
});
console.log("artifact bridge tests passed");