Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/agents/verifyagent.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ module.exports = async function handler(req, res) {
}

try {
const verification = await verifyReceipt(req.body.receipt);
const verification = await verifyReceipt(req.body.receipt, req.verifyOptions || {});
return res.status(200).json({
agent: 'verifyagent.eth',
action: 'verify_receipt',
Expand Down
24 changes: 4 additions & 20 deletions api/ens/owned.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

const { createMainnetProvider, getMainnetRpcUrl } = require('../../lib/mainnetRpc');

function getAddressInput(req) {
const queryAddress = req && req.query && typeof req.query.address === 'string' ? req.query.address : '';
if (queryAddress) return queryAddress;
Expand All @@ -16,22 +18,6 @@ function hasProviderConfig() {
);
}

function getMainnetRpcUrl() {
const direct = [
process.env.ETHEREUM_RPC_URL,
process.env.MAINNET_RPC_URL,
process.env.ALCHEMY_ETHEREUM_RPC_URL,
process.env.ALCHEMY_ETH_RPC_URL,
process.env.ETH_RPC_URL,
].find((value) => typeof value === 'string' && value.trim());
if (direct) return direct.trim();

const alchemyKey = [process.env.ALCHEMY_API_KEY, process.env.ALCHEMY_ETH_API_KEY]
.find((value) => typeof value === 'string' && value.trim());
if (alchemyKey) return `https://eth-mainnet.g.alchemy.com/v2/${alchemyKey.trim()}`;
return '';
}

function normalizeAddress(raw) {
const value = String(raw || '').trim();
if (!/^0x[a-fA-F0-9]{40}$/.test(value)) throw new Error('invalid_address');
Expand All @@ -40,10 +26,8 @@ function normalizeAddress(raw) {

async function reverseResolvePrimary(address) {
try {
const { ethers } = require('ethers');
const rpcUrl = getMainnetRpcUrl();
if (!rpcUrl) return null;
const provider = new ethers.JsonRpcProvider(rpcUrl);
const provider = createMainnetProvider();
if (!provider) return null;
const primary = await provider.lookupAddress(address);
return primary || null;
} catch {
Expand Down
2 changes: 1 addition & 1 deletion api/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ module.exports = async function handler(req, res) {
: req.body;

try {
const result = await verifyReceipt(payload);
const result = await verifyReceipt(payload, req.verifyOptions || {});
return res.status(200).json(result);
} catch (error) {
return res.status(500).json({ ok: false, status: 'INVALID', reason: `Unexpected verification failure: ${error.message}` });
Expand Down
6 changes: 3 additions & 3 deletions docs/ops/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,16 @@ This document lists production-focused environment variables used by `commandlay

For production ENS-related lookups, configure **one explicit mainnet RPC endpoint**:

- Preferred: `ETHEREUM_RPC_URL`
- Backward-compatible alternatives: `MAINNET_RPC_URL`, `ALCHEMY_ETHEREUM_RPC_URL`
- Preferred: `ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/<key>`
- Backward-compatible alternatives: `MAINNET_RPC_URL`, `ALCHEMY_ETHEREUM_RPC_URL`, `ALCHEMY_ETH_RPC_URL`, `ETH_RPC_URL`
- If only `ALCHEMY_API_KEY` is set, the app constructs `https://eth-mainnet.g.alchemy.com/v2/<key>`.
- Default/provider fallback is last resort and can hit shared-rate limits.

## Environment variable table

| Env var | Required | Used by | Production purpose | Safe example placeholder |
|---|---|---|---|---|
| `ETHEREUM_RPC_URL` | Recommended | `api/ens/owned.js` | Primary explicit Ethereum mainnet RPC for ENS reverse lookup without shared default provider throttling. | `https://mainnet.example-rpc.com/v1/<project-id>` |
| `ETHEREUM_RPC_URL` | Recommended | `api/ens/owned.js`, `lib/verifyReceipt.js` (`/api/verify`, `/api/agents/verifyagent`) | Primary explicit Ethereum mainnet RPC for ENS reverse lookup and receipt ENS TXT verification without shared default provider throttling. | `https://mainnet.example-rpc.com/v1/<project-id>` |
| `MAINNET_RPC_URL` | Optional | `api/ens/owned.js` | Backward-compatible alternative mainnet RPC variable. | `https://mainnet.example-rpc.com/v1/<project-id>` |
| `ALCHEMY_ETHEREUM_RPC_URL` | Optional | `api/ens/owned.js` | Backward-compatible explicit Alchemy HTTPS RPC URL. | `https://eth-mainnet.g.alchemy.com/v2/<alchemy-key>` |
| `ALCHEMY_API_KEY` | Optional | `api/ens/owned.js` | If set (without explicit RPC URL), converted to an Alchemy mainnet HTTPS RPC URL. | `<alchemy-api-key>` |
Expand Down
28 changes: 28 additions & 0 deletions lib/mainnetRpc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use strict';

function getMainnetRpcUrl(env = process.env) {
const direct = [
env.ETHEREUM_RPC_URL,
env.MAINNET_RPC_URL,
env.ALCHEMY_ETHEREUM_RPC_URL,
env.ALCHEMY_ETH_RPC_URL,
env.ETH_RPC_URL,
].find((value) => typeof value === 'string' && value.trim());
if (direct) return direct.trim();

const alchemyKey = env.ALCHEMY_API_KEY;
if (typeof alchemyKey === 'string' && alchemyKey.trim()) {
return `https://eth-mainnet.g.alchemy.com/v2/${alchemyKey.trim()}`;
}

return '';
}

function createMainnetProvider(env = process.env) {
const rpcUrl = getMainnetRpcUrl(env);
if (!rpcUrl) return null;
const { ethers } = require('ethers');
return new ethers.JsonRpcProvider(rpcUrl);
}

module.exports = { getMainnetRpcUrl, createMainnetProvider };
15 changes: 11 additions & 4 deletions lib/verifyReceipt.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const { webcrypto } = require('node:crypto');
const { createMainnetProvider } = require('./mainnetRpc');

const subtle = webcrypto.subtle;

Expand Down Expand Up @@ -69,8 +70,12 @@ async function verifyCanonicalSignature(canonicalStr, signatureBase64, publicKey
);
}

async function defaultTextResolver() {
return null;
async function defaultTextResolver(name, key, options = {}) {
const provider = options.provider || createMainnetProvider(options.env);
if (!provider) return null;
const resolver = await provider.getResolver(name);
if (!resolver) return null;
return resolver.getText(key);
}

async function resolveSignerFromEns(signerEnsName, options = {}) {
Expand All @@ -83,7 +88,7 @@ async function resolveSignerFromEns(signerEnsName, options = {}) {
let resolutionError = false;
for (const key of requiredKeys) {
try {
const value = await resolver(signerEnsName, key);
const value = await resolver(signerEnsName, key, options);
if (!value) {
liveOk = false;
break;
Expand Down Expand Up @@ -223,7 +228,9 @@ async function verifyReceipt(receiptInput, options = {}) {
status: ok ? 'VERIFIED' : 'INVALID',
reason: ok
? 'Receipt verification passed.'
: (ens.errorCode === 'ens_key_unavailable' ? 'ens_key_unavailable' : 'Receipt is invalid, tampered, or does not match the signer key metadata.'),
: (ens.errorCode === 'ens_key_unavailable'
? 'ens_key_unavailable'
: (ens.errorCode === 'key_resolution_failed' ? 'key_resolution_failed' : 'Receipt is invalid, tampered, or does not match the signer key metadata.')),
signer: receipt?.signer || null,
verb: receipt?.verb || null,
hash: recomputedHash,
Expand Down
54 changes: 54 additions & 0 deletions tests/api-verify.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { webcrypto } = require('node:crypto');

const handler = require('../api/verify');

Expand All @@ -18,6 +19,34 @@ function makeRes() {
};
}

const subtle = webcrypto.subtle;

function canonicalize(value) {
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(',')}}`;
}

async function makeRuntimeReceipt() {
const keyPair = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
const rawPub = Buffer.from(await subtle.exportKey('raw', keyPair.publicKey)).toString('base64');
const receipt = {
signer: 'runtime.commandlayer.eth',
verb: 'agent.execute',
ts: '2026-05-20T00:00:00.000Z',
input: { task: 'verify', content: 'canonical' },
output: { ok: true },
execution: { runtime: 'prod', run_id: 'run_1' },
};
const payload = { signer: receipt.signer, verb: receipt.verb, input: receipt.input, output: receipt.output, execution: receipt.execution, ts: receipt.ts };
const canonical = canonicalize(payload);
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(canonical));
const hashHex = Buffer.from(digest).toString('hex');
const sigBytes = await subtle.sign({ name: 'Ed25519' }, keyPair.privateKey, new TextEncoder().encode(hashHex));
receipt.metadata = { proof: { canonicalization: 'json.sorted_keys.v1', hash: { alg: 'SHA-256', value: hashHex }, signature: { alg: 'Ed25519', kid: 'vC4WbcNoq2znSCiQ', value: Buffer.from(sigBytes).toString('base64') }, signer_id: 'runtime.commandlayer.eth' } };
return { receipt, rawPub };
}

const sampleReceipt = JSON.parse(
fs.readFileSync(path.join(__dirname, 'fixtures', 'canonical-receipt.sample.json'), 'utf8')
);
Expand Down Expand Up @@ -94,3 +123,28 @@ test('POST /api/verify oversized body => 413', async () => {
assert.equal(res.body.ok, false);
assert.equal(res.body.status, 'INVALID');
});


test('POST /api/verify can verify with injected ENS resolver', async () => {
const { receipt, rawPub } = await makeRuntimeReceipt();
const req = {
method: 'POST',
body: receipt,
verifyOptions: {
ens: {
textResolver: async (_ens, key) => ({
'cl.sig.pub': `ed25519:${rawPub}`,
'cl.sig.kid': 'vC4WbcNoq2znSCiQ',
'cl.sig.canonical': 'json.sorted_keys.v1',
'cl.receipt.signer': 'runtime.commandlayer.eth',
})[key] || null,
},
},
};
const res = makeRes();
await handler(req, res);
assert.equal(res.statusCode, 200);
assert.equal(res.body.status, 'VERIFIED');
assert.equal(res.body.public_key_source, 'ens_txt');
assert.equal(res.body.ens_resolved, true);
});
21 changes: 21 additions & 0 deletions tests/mainnet-rpc.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');

const { getMainnetRpcUrl } = require('../lib/mainnetRpc');

test('getMainnetRpcUrl prefers ETHEREUM_RPC_URL', () => {
const env = {
ETHEREUM_RPC_URL: 'https://rpc-1.example',
MAINNET_RPC_URL: 'https://rpc-2.example',
ALCHEMY_ETHEREUM_RPC_URL: 'https://rpc-3.example',
ALCHEMY_API_KEY: 'abc123',
};
assert.equal(getMainnetRpcUrl(env), 'https://rpc-1.example');
});

test('getMainnetRpcUrl maps ALCHEMY_API_KEY to Alchemy HTTPS URL', () => {
const env = { ALCHEMY_API_KEY: 'abc123' };
assert.equal(getMainnetRpcUrl(env), 'https://eth-mainnet.g.alchemy.com/v2/abc123');
});
28 changes: 27 additions & 1 deletion tests/verifyReceipt-runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ test('fails with key_resolution_failed when ENS resolver throws', async () => {
});

assert.equal(out.status, 'INVALID');
assert.equal(out.reason, 'Receipt is invalid, tampered, or does not match the signer key metadata.');
assert.equal(out.reason, 'key_resolution_failed');
assert.equal(out.debug.key_resolution_error, 'key_resolution_failed');
assert.equal(out.public_key_source, 'ens_txt');
});
Expand Down Expand Up @@ -218,3 +218,29 @@ test('multi-signature proof shape does not crash runtime verifier', async () =>
const out = await verifyReceipt(receipt, { ens: { textResolver: makeTextResolver(rawPub) } });
assert.equal(out.status, 'INVALID');
});


test('uses configured provider path for ENS TXT resolution when no textResolver injected', async () => {
const { receipt, rawPub } = await makeRuntimeReceipt();
const calls = [];
const provider = {
async getResolver(name) {
calls.push(name);
return {
async getText(key) {
return ({
'cl.sig.pub': `ed25519:${rawPub}`,
'cl.sig.kid': 'vC4WbcNoq2znSCiQ',
'cl.sig.canonical': 'json.sorted_keys.v1',
'cl.receipt.signer': 'runtime.commandlayer.eth',
})[key] || null;
},
};
},
};

const out = await verifyReceipt(receipt, { ens: { provider } });
assert.equal(out.status, 'VERIFIED');
assert.equal(calls.length > 0, true);
assert.equal(out.public_key_source, 'ens_txt');
});
Loading