mirror of
https://github.com/hanzoskill/blockchain-attestation.git
synced 2026-10-30 21:01:46 +00:00
927 lines
30 KiB
JavaScript
927 lines
30 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import process from 'node:process';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { createRequire } from 'node:module';
|
|
|
|
// Use createRequire for packages with ESM resolution issues
|
|
const require = createRequire(import.meta.url);
|
|
const { Command } = require('commander');
|
|
const {
|
|
EAS,
|
|
NO_EXPIRATION,
|
|
SchemaEncoder,
|
|
SchemaRegistry,
|
|
Offchain,
|
|
OffchainAttestationVersion
|
|
} = require('@ethereum-attestation-service/eas-sdk');
|
|
const { ethers } = require('ethers');
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const CHAINS_PATH = path.join(__dirname, 'chains.json');
|
|
const SCHEMAS_PATH = path.join(__dirname, 'schemas.json');
|
|
|
|
function readJson(filePath) {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
return JSON.parse(raw);
|
|
} catch (err) {
|
|
throw new Error(`Failed to read JSON: ${filePath}: ${err?.message || String(err)}`);
|
|
}
|
|
}
|
|
|
|
function writeJson(filePath, obj) {
|
|
const tmp = `${filePath}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
|
fs.renameSync(tmp, filePath);
|
|
}
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function ok(payload) {
|
|
process.stdout.write(JSON.stringify({ success: true, ...payload }) + '\n');
|
|
}
|
|
|
|
function fail(code, message, details) {
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
success: false,
|
|
error: { code, message, details: details ?? null }
|
|
}) + '\n'
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
function requireValue(name, value) {
|
|
if (value === undefined || value === null || value === '') {
|
|
throw new Error(`Missing required value: ${name}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function isHexPrefixed(s) {
|
|
return typeof s === 'string' && s.startsWith('0x');
|
|
}
|
|
|
|
function normalizeHexBytes32(hex) {
|
|
if (typeof hex !== 'string') throw new Error('Expected hex string');
|
|
const h = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
if (!/^[0-9a-fA-F]+$/.test(h)) throw new Error(`Invalid hex: ${hex}`);
|
|
if (h.length !== 64) throw new Error(`Expected 32 bytes hex (64 nybbles). Got length ${h.length}: ${hex}`);
|
|
return '0x' + h.toLowerCase();
|
|
}
|
|
|
|
function normalizeUid(uid) {
|
|
return normalizeHexBytes32(uid);
|
|
}
|
|
|
|
function normalizeAddress(addr) {
|
|
if (!ethers.isAddress(addr)) throw new Error(`Invalid address: ${addr}`);
|
|
return ethers.getAddress(addr);
|
|
}
|
|
|
|
function zeroAddress() {
|
|
return ethers.ZeroAddress;
|
|
}
|
|
|
|
function zeroUid() {
|
|
return ethers.ZeroHash;
|
|
}
|
|
|
|
function sha256Bytes32(data) {
|
|
const hash = crypto.createHash('sha256').update(data).digest('hex');
|
|
return '0x' + hash;
|
|
}
|
|
|
|
function keccakBytes32(data) {
|
|
// ethers.keccak256 expects BytesLike
|
|
return ethers.keccak256(data);
|
|
}
|
|
|
|
function hashFromText(text, algo) {
|
|
const buf = Buffer.from(String(text), 'utf8');
|
|
if (algo === 'keccak256') return keccakBytes32(buf);
|
|
return sha256Bytes32(buf);
|
|
}
|
|
|
|
function hashFromFile(filePath, algo) {
|
|
const buf = fs.readFileSync(filePath);
|
|
if (algo === 'keccak256') return keccakBytes32(buf);
|
|
return sha256Bytes32(buf);
|
|
}
|
|
|
|
function parseSchemaString(schemaString) {
|
|
// Example: "bytes32 taskHash, bytes32 outputHash, string agentId, string metadata"
|
|
const parts = String(schemaString)
|
|
.split(',')
|
|
.map((p) => p.trim())
|
|
.filter(Boolean);
|
|
|
|
const fields = parts.map((p) => {
|
|
// split by whitespace, first token is type, last token is name
|
|
const tokens = p.split(/\s+/).filter(Boolean);
|
|
if (tokens.length < 2) throw new Error(`Invalid schema field: ${p}`);
|
|
const type = tokens[0];
|
|
const name = tokens[tokens.length - 1];
|
|
return { type, name };
|
|
});
|
|
|
|
return {
|
|
fields,
|
|
types: fields.map((f) => f.type),
|
|
names: fields.map((f) => f.name)
|
|
};
|
|
}
|
|
|
|
function decodeEncodedData(schemaString, encodedData) {
|
|
const { types, names } = parseSchemaString(schemaString);
|
|
const coder = ethers.AbiCoder.defaultAbiCoder();
|
|
const decoded = coder.decode(types, encodedData);
|
|
const out = {};
|
|
for (let i = 0; i < names.length; i++) out[names[i]] = decoded[i];
|
|
return out;
|
|
}
|
|
|
|
function loadChains() {
|
|
return readJson(CHAINS_PATH);
|
|
}
|
|
|
|
function loadSchemas() {
|
|
return readJson(SCHEMAS_PATH);
|
|
}
|
|
|
|
function resolveChainKey(chainArg) {
|
|
return (chainArg || process.env.EAS_CHAIN || 'base').trim();
|
|
}
|
|
|
|
function getChainConfig(chains, chainKey) {
|
|
const cfg = chains[chainKey];
|
|
if (!cfg) {
|
|
const known = Object.keys(chains).join(', ');
|
|
throw new Error(`Unknown chain key: ${chainKey}. Known: ${known}`);
|
|
}
|
|
return cfg;
|
|
}
|
|
|
|
function buildExplorerUrls(chainCfg, uid, mode) {
|
|
const base = String(chainCfg.explorer).replace(/\/+$/, '');
|
|
if (mode === 'onchain') return { view: `${base}/attestation/view/${uid}` };
|
|
if (mode === 'offchain') return { view: `${base}/offchain/attestation/view/${uid}` };
|
|
if (mode === 'schema') return { view: `${base}/schema/view/${uid}` };
|
|
return { view: base };
|
|
}
|
|
|
|
function getRpcUrl(argRpc) {
|
|
return argRpc || process.env.EAS_RPC_URL || '';
|
|
}
|
|
|
|
function getPrivateKey(argPk) {
|
|
return argPk || process.env.EAS_PRIVATE_KEY || '';
|
|
}
|
|
|
|
function makeProvider(rpcUrl, chainId) {
|
|
if (!rpcUrl) return null;
|
|
// Provide chainId to avoid extra network calls when possible
|
|
try {
|
|
return new ethers.JsonRpcProvider(rpcUrl, chainId);
|
|
} catch {
|
|
return new ethers.JsonRpcProvider(rpcUrl);
|
|
}
|
|
}
|
|
|
|
function makeSigner(privateKey, provider) {
|
|
if (!privateKey) return null;
|
|
if (provider) return new ethers.Wallet(privateKey, provider);
|
|
return new ethers.Wallet(privateKey);
|
|
}
|
|
|
|
function coerceBool(v, defaultValue) {
|
|
if (v === undefined || v === null) return defaultValue;
|
|
if (typeof v === 'boolean') return v;
|
|
const s = String(v).toLowerCase().trim();
|
|
if (['true', '1', 'yes', 'y', 'on'].includes(s)) return true;
|
|
if (['false', '0', 'no', 'n', 'off'].includes(s)) return false;
|
|
throw new Error(`Invalid boolean: ${v}`);
|
|
}
|
|
|
|
function ensureDirForFile(filePath) {
|
|
const dir = path.dirname(filePath);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
function safeReadTextFile(filePath) {
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
}
|
|
|
|
function parseJsonOrRawString(s) {
|
|
const trimmed = String(s ?? '').trim();
|
|
if (!trimmed) return '';
|
|
try {
|
|
// If it's JSON, normalize spacing by re-stringifying
|
|
return JSON.stringify(JSON.parse(trimmed));
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
async function schemaRegister({ chainKey, rpcUrl, privateKey, resolver, revocable, writeBack }) {
|
|
const chains = loadChains();
|
|
const schemas = loadSchemas();
|
|
const chainCfg = getChainConfig(chains, chainKey);
|
|
|
|
const schemaString = schemas?.schema?.string;
|
|
if (!schemaString) throw new Error('schemas.json missing schema.string');
|
|
|
|
const resolverAddress = resolver || schemas?.schema?.resolver || zeroAddress();
|
|
const revocableFlag = revocable ?? schemas?.schema?.revocable ?? true;
|
|
|
|
const provider = makeProvider(rpcUrl, chainCfg.chainId);
|
|
if (!provider) throw new Error('EAS_RPC_URL (or --rpc) is required to register schemas');
|
|
|
|
const signer = makeSigner(privateKey, provider);
|
|
if (!signer) throw new Error('EAS_PRIVATE_KEY (or --private-key) is required to register schemas');
|
|
|
|
const schemaRegistry = new SchemaRegistry(chainCfg.schemaRegistry);
|
|
schemaRegistry.connect(signer);
|
|
|
|
const tx = await schemaRegistry.register({
|
|
schema: schemaString,
|
|
resolverAddress: resolverAddress,
|
|
revocable: revocableFlag
|
|
});
|
|
|
|
const waitResult = await tx.wait();
|
|
|
|
let schemaUid = null;
|
|
|
|
// Most EAS SDK Transaction.wait() calls resolve to the UID string.
|
|
if (typeof waitResult === 'string' && isHexPrefixed(waitResult) && waitResult.length === 66) {
|
|
schemaUid = waitResult;
|
|
}
|
|
|
|
// Fallback: attempt to extract from receipt logs if available.
|
|
if (!schemaUid) {
|
|
const receipt = tx?.receipt;
|
|
if (receipt?.logs && schemaRegistry?.contract?.interface) {
|
|
for (const log of receipt.logs) {
|
|
try {
|
|
const parsed = schemaRegistry.contract.interface.parseLog(log);
|
|
if (parsed?.name?.toLowerCase() === 'registered') {
|
|
// Some versions emit: Registered(bytes32 uid, address registrar, bytes32 schemaUID, string schema, address resolver, bool revocable)
|
|
const possible = parsed?.args?.uid || parsed?.args?.schemaUID || parsed?.args?.[0];
|
|
if (typeof possible === 'string' && isHexPrefixed(possible) && possible.length === 66) {
|
|
schemaUid = possible;
|
|
break;
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Last-resort: try getSchemaUID if present.
|
|
if (!schemaUid && typeof schemaRegistry.getSchemaUID === 'function') {
|
|
try {
|
|
const uidMaybe = await schemaRegistry.getSchemaUID({
|
|
schema: schemaString,
|
|
resolverAddress: resolverAddress,
|
|
revocable: revocableFlag
|
|
});
|
|
if (typeof uidMaybe === 'string' && isHexPrefixed(uidMaybe) && uidMaybe.length === 66) schemaUid = uidMaybe;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (!schemaUid) throw new Error('Failed to determine schema UID after registration');
|
|
|
|
if (writeBack) {
|
|
schemas.uids = schemas.uids || {};
|
|
schemas.uids[chainKey] = schemaUid;
|
|
schemas.schema = schemas.schema || {};
|
|
schemas.schema.resolver = resolverAddress;
|
|
schemas.schema.revocable = revocableFlag;
|
|
writeJson(SCHEMAS_PATH, schemas);
|
|
}
|
|
|
|
return {
|
|
chain: chainKey,
|
|
chainName: chainCfg.name,
|
|
schemaUid,
|
|
schemaString,
|
|
resolver: resolverAddress,
|
|
revocable: revocableFlag,
|
|
explorer: buildExplorerUrls(chainCfg, schemaUid, 'schema').view
|
|
};
|
|
}
|
|
|
|
function requireSchemaUid(schemas, chainKey) {
|
|
const uid = schemas?.uids?.[chainKey];
|
|
if (uid && typeof uid === 'string' && uid.startsWith('0x') && uid.length === 66) return uid;
|
|
throw new Error(`Schema UID missing for chain ${chainKey}. Run: node attest.mjs schema register --chain ${chainKey}`);
|
|
}
|
|
|
|
async function createAttestation({
|
|
mode,
|
|
chainKey,
|
|
rpcUrl,
|
|
privateKey,
|
|
recipient,
|
|
taskHash,
|
|
outputHash,
|
|
agentId,
|
|
metadata,
|
|
savePath,
|
|
timestampAfter
|
|
}) {
|
|
const chains = loadChains();
|
|
const schemas = loadSchemas();
|
|
const chainCfg = getChainConfig(chains, chainKey);
|
|
|
|
const schemaString = schemas?.schema?.string;
|
|
if (!schemaString) throw new Error('schemas.json missing schema.string');
|
|
|
|
const schemaUid = requireSchemaUid(schemas, chainKey);
|
|
|
|
const recipientAddr = recipient ? normalizeAddress(recipient) : zeroAddress();
|
|
const agent = agentId || process.env.BOT_AGENT_ID || process.env.AGENT_ID || 'bot';
|
|
const metadataStr = metadata ?? '{}';
|
|
|
|
const schemaEncoder = new SchemaEncoder(schemaString);
|
|
const encodedData = schemaEncoder.encodeData([
|
|
{ name: 'taskHash', value: normalizeHexBytes32(taskHash), type: 'bytes32' },
|
|
{ name: 'outputHash', value: normalizeHexBytes32(outputHash), type: 'bytes32' },
|
|
{ name: 'agentId', value: String(agent), type: 'string' },
|
|
{ name: 'metadata', value: String(metadataStr), type: 'string' }
|
|
]);
|
|
|
|
const commonRecord = {
|
|
createdAt: nowIso(),
|
|
chain: chainKey,
|
|
chainName: chainCfg.name,
|
|
mode,
|
|
schemaUid,
|
|
schemaString,
|
|
recipient: recipientAddr,
|
|
agentId: agent,
|
|
taskHash: normalizeHexBytes32(taskHash),
|
|
outputHash: normalizeHexBytes32(outputHash),
|
|
metadata: metadataStr,
|
|
encodedData
|
|
};
|
|
|
|
if (mode === 'offchain') {
|
|
const signer = makeSigner(privateKey, null);
|
|
if (!signer) throw new Error('EAS_PRIVATE_KEY (or --private-key) is required for offchain signing');
|
|
|
|
// Prefer EAS.getOffchain() when an RPC URL is present (lets the SDK derive domain values from chain).
|
|
const provider = makeProvider(rpcUrl, chainCfg.chainId);
|
|
let offchainAttestation = null;
|
|
|
|
if (provider) {
|
|
const signerWithProvider = makeSigner(privateKey, provider);
|
|
const eas = new EAS(chainCfg.eas);
|
|
// Connect signer so the SDK can read domain separator when needed.
|
|
eas.connect(signerWithProvider);
|
|
const offchain = await eas.getOffchain();
|
|
offchainAttestation = await offchain.signOffchainAttestation(
|
|
{
|
|
schema: schemaUid,
|
|
recipient: recipientAddr,
|
|
time: BigInt(Math.floor(Date.now() / 1000)),
|
|
expirationTime: NO_EXPIRATION,
|
|
revocable: true,
|
|
refUID: zeroUid(),
|
|
data: encodedData
|
|
},
|
|
signerWithProvider
|
|
);
|
|
} else {
|
|
// Offline path: use static chain config.
|
|
const cfg = {
|
|
address: chainCfg.eas,
|
|
version: chainCfg.easVersion,
|
|
chainId: chainCfg.chainId
|
|
};
|
|
const offchain = new Offchain(cfg, OffchainAttestationVersion.Version2);
|
|
offchainAttestation = await offchain.signOffchainAttestation(
|
|
{
|
|
schema: schemaUid,
|
|
recipient: recipientAddr,
|
|
time: BigInt(Math.floor(Date.now() / 1000)),
|
|
expirationTime: NO_EXPIRATION,
|
|
revocable: true,
|
|
refUID: zeroUid(),
|
|
data: encodedData
|
|
},
|
|
signer
|
|
);
|
|
}
|
|
|
|
const uid = offchainAttestation?.uid || offchainAttestation?.sig?.uid;
|
|
if (!uid) throw new Error('Offchain attestation did not include a UID');
|
|
|
|
const record = {
|
|
...commonRecord,
|
|
uid,
|
|
verifyUrl: buildExplorerUrls(chainCfg, uid, 'offchain').view,
|
|
offchainAttestation
|
|
};
|
|
|
|
let timestampTx = null;
|
|
if (timestampAfter) {
|
|
if (!provider) throw new Error('EAS_RPC_URL (or --rpc) is required to timestamp onchain');
|
|
const signerWithProvider = makeSigner(privateKey, provider);
|
|
const eas = new EAS(chainCfg.eas);
|
|
eas.connect(signerWithProvider);
|
|
const tx = await eas.timestamp(normalizeUid(uid));
|
|
await tx.wait();
|
|
timestampTx = {
|
|
txHash: tx?.tx?.hash || tx?.receipt?.transactionHash || null
|
|
};
|
|
record.timestamp = timestampTx;
|
|
}
|
|
|
|
if (savePath) {
|
|
ensureDirForFile(savePath);
|
|
fs.writeFileSync(savePath, JSON.stringify(record, null, 2) + '\n', 'utf8');
|
|
}
|
|
|
|
return record;
|
|
}
|
|
|
|
if (mode === 'onchain') {
|
|
const rpc = rpcUrl;
|
|
if (!rpc) throw new Error('EAS_RPC_URL (or --rpc) is required for onchain attestations');
|
|
|
|
const provider = makeProvider(rpc, chainCfg.chainId);
|
|
const signer = makeSigner(privateKey, provider);
|
|
if (!signer) throw new Error('EAS_PRIVATE_KEY (or --private-key) is required for onchain attestations');
|
|
|
|
const eas = new EAS(chainCfg.eas);
|
|
eas.connect(signer);
|
|
|
|
const tx = await eas.attest({
|
|
schema: schemaUid,
|
|
data: {
|
|
recipient: recipientAddr,
|
|
expirationTime: NO_EXPIRATION,
|
|
revocable: true,
|
|
refUID: zeroUid(),
|
|
data: encodedData
|
|
}
|
|
});
|
|
|
|
const uid = await tx.wait();
|
|
|
|
const record = {
|
|
...commonRecord,
|
|
uid,
|
|
verifyUrl: buildExplorerUrls(chainCfg, uid, 'onchain').view,
|
|
tx: {
|
|
txHash: tx?.tx?.hash || tx?.receipt?.transactionHash || null,
|
|
receipt: tx?.receipt || null
|
|
}
|
|
};
|
|
|
|
if (savePath) {
|
|
ensureDirForFile(savePath);
|
|
fs.writeFileSync(savePath, JSON.stringify(record, null, 2) + '\n', 'utf8');
|
|
}
|
|
|
|
return record;
|
|
}
|
|
|
|
throw new Error(`Unknown mode: ${mode}`);
|
|
}
|
|
|
|
async function verifyOnchain({ chainKey, rpcUrl, uid }) {
|
|
const chains = loadChains();
|
|
const schemas = loadSchemas();
|
|
const chainCfg = getChainConfig(chains, chainKey);
|
|
|
|
const schemaString = schemas?.schema?.string;
|
|
if (!schemaString) throw new Error('schemas.json missing schema.string');
|
|
|
|
const rpc = rpcUrl;
|
|
if (!rpc) throw new Error('EAS_RPC_URL (or --rpc) is required to verify onchain attestations');
|
|
|
|
const provider = makeProvider(rpc, chainCfg.chainId);
|
|
|
|
const eas = new EAS(chainCfg.eas);
|
|
eas.connect(provider);
|
|
|
|
// EAS SDK exposes getAttestation(uid) on many versions. If it is unavailable, return a minimal response.
|
|
let attestation = null;
|
|
try {
|
|
if (typeof eas.getAttestation === 'function') {
|
|
attestation = await eas.getAttestation(normalizeUid(uid));
|
|
} else if (typeof eas.getAttestation === 'undefined') {
|
|
throw new Error('EAS SDK method getAttestation not available');
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
uid: normalizeUid(uid),
|
|
chain: chainKey,
|
|
chainName: chainCfg.name,
|
|
verifyUrl: buildExplorerUrls(chainCfg, normalizeUid(uid), 'onchain').view,
|
|
warning: 'Local fetch failed. Use verifyUrl to inspect on the explorer.',
|
|
error: String(err?.message || err)
|
|
};
|
|
}
|
|
|
|
const decoded = attestation?.data ? decodeEncodedData(schemaString, attestation.data) : null;
|
|
|
|
return {
|
|
uid: normalizeUid(uid),
|
|
chain: chainKey,
|
|
chainName: chainCfg.name,
|
|
verifyUrl: buildExplorerUrls(chainCfg, normalizeUid(uid), 'onchain').view,
|
|
attestation,
|
|
decoded
|
|
};
|
|
}
|
|
|
|
function normalizeOffchainPayload(obj) {
|
|
if (!obj || typeof obj !== 'object') throw new Error('Invalid offchain payload (expected object)');
|
|
|
|
// Accept either:
|
|
// 1) { signer, sig }
|
|
// 2) { offchainAttestation: { signer, sig } }
|
|
// 3) { offchainAttestation: { uid, ... } } (best effort)
|
|
const directSigner = obj.signer;
|
|
const directSig = obj.sig;
|
|
|
|
if (directSigner && directSig) return { signer: directSigner, sig: directSig };
|
|
|
|
if (obj.offchainAttestation?.signer && obj.offchainAttestation?.sig) {
|
|
return { signer: obj.offchainAttestation.signer, sig: obj.offchainAttestation.sig };
|
|
}
|
|
|
|
// If this is the raw object returned by signOffchainAttestation, it usually includes { signer, sig } already.
|
|
// Some versions may return { uid, ... } only. Fail clearly.
|
|
throw new Error('Unsupported offchain payload format. Expected fields: signer and sig.');
|
|
}
|
|
|
|
async function verifyOffchain({ payload }) {
|
|
const { signer, sig } = normalizeOffchainPayload(payload);
|
|
|
|
const domain = sig?.domain;
|
|
const message = sig?.message;
|
|
if (!domain?.verifyingContract || domain?.chainId == null || !domain?.version) {
|
|
throw new Error('Offchain payload missing EIP712 domain fields');
|
|
}
|
|
|
|
const easCfg = {
|
|
address: domain.verifyingContract,
|
|
version: domain.version,
|
|
chainId: Number(domain.chainId)
|
|
};
|
|
|
|
const offchainVersion = message?.version ?? OffchainAttestationVersion.Version2;
|
|
const offchain = new Offchain(easCfg, offchainVersion);
|
|
|
|
const isValid = offchain.verifyOffchainAttestationSignature(signer, sig);
|
|
|
|
return {
|
|
uid: sig?.uid || null,
|
|
chainId: easCfg.chainId,
|
|
verifyingContract: easCfg.address,
|
|
domainVersion: easCfg.version,
|
|
valid: Boolean(isValid)
|
|
};
|
|
}
|
|
|
|
async function timestampUid({ chainKey, rpcUrl, privateKey, uid }) {
|
|
const chains = loadChains();
|
|
const chainCfg = getChainConfig(chains, chainKey);
|
|
|
|
const rpc = rpcUrl;
|
|
if (!rpc) throw new Error('EAS_RPC_URL (or --rpc) is required to timestamp');
|
|
|
|
const provider = makeProvider(rpc, chainCfg.chainId);
|
|
const signer = makeSigner(privateKey, provider);
|
|
if (!signer) throw new Error('EAS_PRIVATE_KEY (or --private-key) is required to timestamp');
|
|
|
|
const eas = new EAS(chainCfg.eas);
|
|
eas.connect(signer);
|
|
|
|
const tx = await eas.timestamp(normalizeUid(uid));
|
|
await tx.wait();
|
|
|
|
return {
|
|
chain: chainKey,
|
|
chainName: chainCfg.name,
|
|
uid: normalizeUid(uid),
|
|
txHash: tx?.tx?.hash || tx?.receipt?.transactionHash || null
|
|
};
|
|
}
|
|
|
|
async function revokeUid({ chainKey, rpcUrl, privateKey, uid, mode }) {
|
|
const chains = loadChains();
|
|
const schemas = loadSchemas();
|
|
const chainCfg = getChainConfig(chains, chainKey);
|
|
|
|
const rpc = rpcUrl;
|
|
if (!rpc) throw new Error('EAS_RPC_URL (or --rpc) is required to revoke');
|
|
|
|
const provider = makeProvider(rpc, chainCfg.chainId);
|
|
const signer = makeSigner(privateKey, provider);
|
|
if (!signer) throw new Error('EAS_PRIVATE_KEY (or --private-key) is required to revoke');
|
|
|
|
const eas = new EAS(chainCfg.eas);
|
|
eas.connect(signer);
|
|
|
|
if (mode === 'offchain') {
|
|
const tx = await eas.revokeOffchain(normalizeUid(uid));
|
|
await tx.wait();
|
|
return {
|
|
chain: chainKey,
|
|
chainName: chainCfg.name,
|
|
mode,
|
|
uid: normalizeUid(uid),
|
|
txHash: tx?.tx?.hash || tx?.receipt?.transactionHash || null
|
|
};
|
|
}
|
|
|
|
if (mode === 'onchain') {
|
|
const schemaUid = requireSchemaUid(schemas, chainKey);
|
|
const tx = await eas.revoke({
|
|
schema: schemaUid,
|
|
data: { uid: normalizeUid(uid) }
|
|
});
|
|
await tx.wait();
|
|
return {
|
|
chain: chainKey,
|
|
chainName: chainCfg.name,
|
|
mode,
|
|
uid: normalizeUid(uid),
|
|
schemaUid,
|
|
txHash: tx?.tx?.hash || tx?.receipt?.transactionHash || null
|
|
};
|
|
}
|
|
|
|
throw new Error(`Unknown revoke mode: ${mode}`);
|
|
}
|
|
|
|
async function main() {
|
|
const program = new Command();
|
|
|
|
program
|
|
.name('attest')
|
|
.description('Create and verify EAS attestations (opinionated defaults for Hanzo Bot)')
|
|
.showHelpAfterError(true);
|
|
|
|
program
|
|
.command('hash')
|
|
.description('Hash a text or file to bytes32 (sha256 default)')
|
|
.option('--algo <algo>', 'sha256 or keccak256', 'sha256')
|
|
.option('--text <text>', 'text to hash')
|
|
.option('--file <path>', 'file path to hash')
|
|
.action((opts) => {
|
|
try {
|
|
const algo = (opts.algo || 'sha256').toLowerCase().trim();
|
|
if (!['sha256', 'keccak256'].includes(algo)) throw new Error('algo must be sha256 or keccak256');
|
|
|
|
const hasText = opts.text !== undefined;
|
|
const hasFile = opts.file !== undefined;
|
|
|
|
if (hasText === hasFile) throw new Error('Provide exactly one of --text or --file');
|
|
|
|
const digest = hasText ? hashFromText(opts.text, algo) : hashFromFile(opts.file, algo);
|
|
|
|
ok({
|
|
algo,
|
|
digest: normalizeHexBytes32(digest),
|
|
source: hasText ? { kind: 'text' } : { kind: 'file', path: opts.file }
|
|
});
|
|
} catch (err) {
|
|
fail('HASH_ERROR', err?.message || String(err));
|
|
}
|
|
});
|
|
|
|
const schemaCmd = program.command('schema').description('Schema management');
|
|
|
|
schemaCmd
|
|
.command('info')
|
|
.description('Show the schema string and configured UIDs')
|
|
.action(() => {
|
|
try {
|
|
const schemas = loadSchemas();
|
|
ok({ schema: schemas.schema, uids: schemas.uids });
|
|
} catch (err) {
|
|
fail('SCHEMA_INFO_ERROR', err?.message || String(err));
|
|
}
|
|
});
|
|
|
|
schemaCmd
|
|
.command('register')
|
|
.description('Register the schema onchain and store the UID into schemas.json')
|
|
.option('--chain <chain>', 'chain key (base or base_sepolia)', 'base')
|
|
.option('--rpc <url>', 'RPC URL (overrides EAS_RPC_URL)')
|
|
.option('--private-key <hex>', 'private key (overrides EAS_PRIVATE_KEY)')
|
|
.option('--resolver <address>', 'resolver address (default zero address)')
|
|
.option('--revocable <bool>', 'true or false (default true)')
|
|
.option('--no-write', 'do not write schemas.json (prints UID only)')
|
|
.action(async (opts) => {
|
|
try {
|
|
const chainKey = resolveChainKey(opts.chain);
|
|
const rpcUrl = getRpcUrl(opts.rpc);
|
|
const privateKey = getPrivateKey(opts.privateKey);
|
|
const resolver = opts.resolver ? normalizeAddress(opts.resolver) : null;
|
|
const revocable = opts.revocable !== undefined ? coerceBool(opts.revocable, true) : null;
|
|
const writeBack = Boolean(opts.write);
|
|
|
|
const result = await schemaRegister({
|
|
chainKey,
|
|
rpcUrl,
|
|
privateKey,
|
|
resolver,
|
|
revocable,
|
|
writeBack
|
|
});
|
|
|
|
ok(result);
|
|
} catch (err) {
|
|
fail('SCHEMA_REGISTER_ERROR', err?.message || String(err));
|
|
}
|
|
});
|
|
|
|
program
|
|
.command('attest')
|
|
.description('Create an attestation (offchain default)')
|
|
.option('--mode <mode>', 'offchain or onchain', 'offchain')
|
|
.option('--chain <chain>', 'chain key (base or base_sepolia)', 'base')
|
|
.option('--rpc <url>', 'RPC URL (overrides EAS_RPC_URL)')
|
|
.option('--private-key <hex>', 'private key (overrides EAS_PRIVATE_KEY)')
|
|
.option('--recipient <address>', 'recipient address (default zero address)')
|
|
.option('--agent-id <id>', 'agent id (default env BOT_AGENT_ID or "bot")')
|
|
.option('--metadata <string>', 'metadata string or JSON')
|
|
.option('--metadata-file <path>', 'read metadata from file')
|
|
.option('--hash-algo <algo>', 'sha256 or keccak256', 'sha256')
|
|
.option('--task-hash <hex>', 'bytes32 task hash (0x...)')
|
|
.option('--task-text <text>', 'task text to hash')
|
|
.option('--task-file <path>', 'task file to hash')
|
|
.option('--output-hash <hex>', 'bytes32 output hash (0x...)')
|
|
.option('--output-text <text>', 'output text to hash')
|
|
.option('--output-file <path>', 'output file to hash')
|
|
.option('--save <path>', 'write a JSON record to this path')
|
|
.option('--timestamp', 'after offchain signing, timestamp the UID onchain')
|
|
.action(async (opts) => {
|
|
try {
|
|
const mode = String(opts.mode || 'offchain').toLowerCase().trim();
|
|
if (!['offchain', 'onchain'].includes(mode)) throw new Error('mode must be offchain or onchain');
|
|
|
|
const chainKey = resolveChainKey(opts.chain);
|
|
const rpcUrl = getRpcUrl(opts.rpc);
|
|
const privateKey = getPrivateKey(opts.privateKey);
|
|
|
|
const algo = String(opts.hashAlgo || 'sha256').toLowerCase().trim();
|
|
if (!['sha256', 'keccak256'].includes(algo)) throw new Error('hash-algo must be sha256 or keccak256');
|
|
|
|
// task hash sources
|
|
const taskSources = [opts.taskHash, opts.taskText, opts.taskFile].filter((v) => v !== undefined);
|
|
if (taskSources.length !== 1) throw new Error('Provide exactly one of --task-hash, --task-text, --task-file');
|
|
|
|
// output hash sources
|
|
const outSources = [opts.outputHash, opts.outputText, opts.outputFile].filter((v) => v !== undefined);
|
|
if (outSources.length !== 1) throw new Error('Provide exactly one of --output-hash, --output-text, --output-file');
|
|
|
|
const taskHash =
|
|
opts.taskHash !== undefined
|
|
? normalizeHexBytes32(opts.taskHash)
|
|
: opts.taskText !== undefined
|
|
? normalizeHexBytes32(hashFromText(opts.taskText, algo))
|
|
: normalizeHexBytes32(hashFromFile(opts.taskFile, algo));
|
|
|
|
const outputHash =
|
|
opts.outputHash !== undefined
|
|
? normalizeHexBytes32(opts.outputHash)
|
|
: opts.outputText !== undefined
|
|
? normalizeHexBytes32(hashFromText(opts.outputText, algo))
|
|
: normalizeHexBytes32(hashFromFile(opts.outputFile, algo));
|
|
|
|
let metadataStr = opts.metadata !== undefined ? parseJsonOrRawString(opts.metadata) : '{}';
|
|
if (opts.metadataFile) {
|
|
metadataStr = parseJsonOrRawString(safeReadTextFile(opts.metadataFile));
|
|
}
|
|
|
|
const recipient = opts.recipient ? normalizeAddress(opts.recipient) : zeroAddress();
|
|
const agentId = opts.agentId ? String(opts.agentId) : null;
|
|
const savePath = opts.save ? String(opts.save) : null;
|
|
const timestampAfter = Boolean(opts.timestamp);
|
|
|
|
const record = await createAttestation({
|
|
mode,
|
|
chainKey,
|
|
rpcUrl,
|
|
privateKey,
|
|
recipient,
|
|
taskHash,
|
|
outputHash,
|
|
agentId,
|
|
metadata: metadataStr,
|
|
savePath,
|
|
timestampAfter
|
|
});
|
|
|
|
ok(record);
|
|
} catch (err) {
|
|
fail('ATTEST_ERROR', err?.message || String(err));
|
|
}
|
|
});
|
|
|
|
program
|
|
.command('verify')
|
|
.description('Verify an onchain UID or an offchain payload')
|
|
.option('--chain <chain>', 'chain key (base or base_sepolia)', 'base')
|
|
.option('--rpc <url>', 'RPC URL (overrides EAS_RPC_URL)')
|
|
.option('--uid <hex>', 'onchain UID to fetch and decode')
|
|
.option('--offchain-file <path>', 'offchain attestation JSON file (as produced by this skill)')
|
|
.option('--offchain-json <json>', 'offchain attestation JSON string')
|
|
.action(async (opts) => {
|
|
try {
|
|
const chainKey = resolveChainKey(opts.chain);
|
|
const rpcUrl = getRpcUrl(opts.rpc);
|
|
|
|
const hasUid = opts.uid !== undefined;
|
|
const hasOffFile = opts.offchainFile !== undefined;
|
|
const hasOffJson = opts.offchainJson !== undefined;
|
|
|
|
const count = [hasUid, hasOffFile, hasOffJson].filter(Boolean).length;
|
|
if (count !== 1) throw new Error('Provide exactly one of --uid, --offchain-file, --offchain-json');
|
|
|
|
if (hasUid) {
|
|
const res = await verifyOnchain({ chainKey, rpcUrl, uid: opts.uid });
|
|
ok(res);
|
|
return;
|
|
}
|
|
|
|
const payload = hasOffFile ? JSON.parse(fs.readFileSync(opts.offchainFile, 'utf8')) : JSON.parse(opts.offchainJson);
|
|
const res = await verifyOffchain({ payload });
|
|
ok(res);
|
|
} catch (err) {
|
|
fail('VERIFY_ERROR', err?.message || String(err));
|
|
}
|
|
});
|
|
|
|
program
|
|
.command('timestamp')
|
|
.description('Timestamp a UID onchain (commonly used to anchor an offchain attestation UID)')
|
|
.option('--chain <chain>', 'chain key (base or base_sepolia)', 'base')
|
|
.option('--rpc <url>', 'RPC URL (overrides EAS_RPC_URL)')
|
|
.option('--private-key <hex>', 'private key (overrides EAS_PRIVATE_KEY)')
|
|
.requiredOption('--uid <hex>', 'UID to timestamp (bytes32)')
|
|
.action(async (opts) => {
|
|
try {
|
|
const chainKey = resolveChainKey(opts.chain);
|
|
const rpcUrl = getRpcUrl(opts.rpc);
|
|
const privateKey = getPrivateKey(opts.privateKey);
|
|
|
|
const res = await timestampUid({ chainKey, rpcUrl, privateKey, uid: opts.uid });
|
|
ok(res);
|
|
} catch (err) {
|
|
fail('TIMESTAMP_ERROR', err?.message || String(err));
|
|
}
|
|
});
|
|
|
|
program
|
|
.command('revoke')
|
|
.description('Revoke an attestation (onchain or offchain revocation record)')
|
|
.option('--mode <mode>', 'onchain or offchain', 'onchain')
|
|
.option('--chain <chain>', 'chain key (base or base_sepolia)', 'base')
|
|
.option('--rpc <url>', 'RPC URL (overrides EAS_RPC_URL)')
|
|
.option('--private-key <hex>', 'private key (overrides EAS_PRIVATE_KEY)')
|
|
.requiredOption('--uid <hex>', 'UID to revoke (bytes32)')
|
|
.action(async (opts) => {
|
|
try {
|
|
const chainKey = resolveChainKey(opts.chain);
|
|
const rpcUrl = getRpcUrl(opts.rpc);
|
|
const privateKey = getPrivateKey(opts.privateKey);
|
|
const mode = String(opts.mode || 'onchain').toLowerCase().trim();
|
|
if (!['onchain', 'offchain'].includes(mode)) throw new Error('mode must be onchain or offchain');
|
|
|
|
const res = await revokeUid({ chainKey, rpcUrl, privateKey, uid: opts.uid, mode });
|
|
ok(res);
|
|
} catch (err) {
|
|
fail('REVOKE_ERROR', err?.message || String(err));
|
|
}
|
|
});
|
|
|
|
await program.parseAsync(process.argv);
|
|
|
|
if (!process.exitCode) process.exitCode = 0;
|
|
}
|
|
|
|
main().catch((err) => {
|
|
fail('FATAL', err?.message || String(err));
|
|
});
|