Skip to content

Commit 560718d

Browse files
committed
Use shared mainnet RPC env helper for verifier ENS lookups
1 parent e1bc0e7 commit 560718d

9 files changed

Lines changed: 150 additions & 30 deletions

File tree

api/agents/verifyagent.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ module.exports = async function handler(req, res) {
7272
}
7373

7474
try {
75-
const verification = await verifyReceipt(req.body.receipt);
75+
const verification = await verifyReceipt(req.body.receipt, req.verifyOptions || {});
7676
return res.status(200).json({
7777
agent: 'verifyagent.eth',
7878
action: 'verify_receipt',

api/ens/owned.js

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict';
22

3+
const { createMainnetProvider, getMainnetRpcUrl } = require('../../lib/mainnetRpc');
4+
35
function getAddressInput(req) {
46
const queryAddress = req && req.query && typeof req.query.address === 'string' ? req.query.address : '';
57
if (queryAddress) return queryAddress;
@@ -16,22 +18,6 @@ function hasProviderConfig() {
1618
);
1719
}
1820

19-
function getMainnetRpcUrl() {
20-
const direct = [
21-
process.env.ETHEREUM_RPC_URL,
22-
process.env.MAINNET_RPC_URL,
23-
process.env.ALCHEMY_ETHEREUM_RPC_URL,
24-
process.env.ALCHEMY_ETH_RPC_URL,
25-
process.env.ETH_RPC_URL,
26-
].find((value) => typeof value === 'string' && value.trim());
27-
if (direct) return direct.trim();
28-
29-
const alchemyKey = [process.env.ALCHEMY_API_KEY, process.env.ALCHEMY_ETH_API_KEY]
30-
.find((value) => typeof value === 'string' && value.trim());
31-
if (alchemyKey) return `https://eth-mainnet.g.alchemy.com/v2/${alchemyKey.trim()}`;
32-
return '';
33-
}
34-
3521
function normalizeAddress(raw) {
3622
const value = String(raw || '').trim();
3723
if (!/^0x[a-fA-F0-9]{40}$/.test(value)) throw new Error('invalid_address');
@@ -40,10 +26,8 @@ function normalizeAddress(raw) {
4026

4127
async function reverseResolvePrimary(address) {
4228
try {
43-
const { ethers } = require('ethers');
44-
const rpcUrl = getMainnetRpcUrl();
45-
if (!rpcUrl) return null;
46-
const provider = new ethers.JsonRpcProvider(rpcUrl);
29+
const provider = createMainnetProvider();
30+
if (!provider) return null;
4731
const primary = await provider.lookupAddress(address);
4832
return primary || null;
4933
} catch {

api/verify.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ module.exports = async function handler(req, res) {
4040
: req.body;
4141

4242
try {
43-
const result = await verifyReceipt(payload);
43+
const result = await verifyReceipt(payload, req.verifyOptions || {});
4444
return res.status(200).json(result);
4545
} catch (error) {
4646
return res.status(500).json({ ok: false, status: 'INVALID', reason: `Unexpected verification failure: ${error.message}` });

docs/ops/environment.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@ This document lists production-focused environment variables used by `commandlay
66

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

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

1414
## Environment variable table
1515

1616
| Env var | Required | Used by | Production purpose | Safe example placeholder |
1717
|---|---|---|---|---|
18-
| `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>` |
18+
| `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>` |
1919
| `MAINNET_RPC_URL` | Optional | `api/ens/owned.js` | Backward-compatible alternative mainnet RPC variable. | `https://mainnet.example-rpc.com/v1/<project-id>` |
2020
| `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>` |
2121
| `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>` |

lib/mainnetRpc.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use strict';
2+
3+
function getMainnetRpcUrl(env = process.env) {
4+
const direct = [
5+
env.ETHEREUM_RPC_URL,
6+
env.MAINNET_RPC_URL,
7+
env.ALCHEMY_ETHEREUM_RPC_URL,
8+
env.ALCHEMY_ETH_RPC_URL,
9+
env.ETH_RPC_URL,
10+
].find((value) => typeof value === 'string' && value.trim());
11+
if (direct) return direct.trim();
12+
13+
const alchemyKey = env.ALCHEMY_API_KEY;
14+
if (typeof alchemyKey === 'string' && alchemyKey.trim()) {
15+
return `https://eth-mainnet.g.alchemy.com/v2/${alchemyKey.trim()}`;
16+
}
17+
18+
return '';
19+
}
20+
21+
function createMainnetProvider(env = process.env) {
22+
const rpcUrl = getMainnetRpcUrl(env);
23+
if (!rpcUrl) return null;
24+
const { ethers } = require('ethers');
25+
return new ethers.JsonRpcProvider(rpcUrl);
26+
}
27+
28+
module.exports = { getMainnetRpcUrl, createMainnetProvider };

lib/verifyReceipt.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

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

56
const subtle = webcrypto.subtle;
67

@@ -69,8 +70,12 @@ async function verifyCanonicalSignature(canonicalStr, signatureBase64, publicKey
6970
);
7071
}
7172

72-
async function defaultTextResolver() {
73-
return null;
73+
async function defaultTextResolver(name, key, options = {}) {
74+
const provider = options.provider || createMainnetProvider(options.env);
75+
if (!provider) return null;
76+
const resolver = await provider.getResolver(name);
77+
if (!resolver) return null;
78+
return resolver.getText(key);
7479
}
7580

7681
async function resolveSignerFromEns(signerEnsName, options = {}) {
@@ -83,7 +88,7 @@ async function resolveSignerFromEns(signerEnsName, options = {}) {
8388
let resolutionError = false;
8489
for (const key of requiredKeys) {
8590
try {
86-
const value = await resolver(signerEnsName, key);
91+
const value = await resolver(signerEnsName, key, options);
8792
if (!value) {
8893
liveOk = false;
8994
break;
@@ -223,7 +228,9 @@ async function verifyReceipt(receiptInput, options = {}) {
223228
status: ok ? 'VERIFIED' : 'INVALID',
224229
reason: ok
225230
? 'Receipt verification passed.'
226-
: (ens.errorCode === 'ens_key_unavailable' ? 'ens_key_unavailable' : 'Receipt is invalid, tampered, or does not match the signer key metadata.'),
231+
: (ens.errorCode === 'ens_key_unavailable'
232+
? 'ens_key_unavailable'
233+
: (ens.errorCode === 'key_resolution_failed' ? 'key_resolution_failed' : 'Receipt is invalid, tampered, or does not match the signer key metadata.')),
227234
signer: receipt?.signer || null,
228235
verb: receipt?.verb || null,
229236
hash: recomputedHash,

tests/api-verify.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const test = require('node:test');
44
const assert = require('node:assert/strict');
55
const fs = require('node:fs');
66
const path = require('node:path');
7+
const { webcrypto } = require('node:crypto');
78

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

@@ -18,6 +19,34 @@ function makeRes() {
1819
};
1920
}
2021

22+
const subtle = webcrypto.subtle;
23+
24+
function canonicalize(value) {
25+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
26+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
27+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`).join(',')}}`;
28+
}
29+
30+
async function makeRuntimeReceipt() {
31+
const keyPair = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
32+
const rawPub = Buffer.from(await subtle.exportKey('raw', keyPair.publicKey)).toString('base64');
33+
const receipt = {
34+
signer: 'runtime.commandlayer.eth',
35+
verb: 'agent.execute',
36+
ts: '2026-05-20T00:00:00.000Z',
37+
input: { task: 'verify', content: 'canonical' },
38+
output: { ok: true },
39+
execution: { runtime: 'prod', run_id: 'run_1' },
40+
};
41+
const payload = { signer: receipt.signer, verb: receipt.verb, input: receipt.input, output: receipt.output, execution: receipt.execution, ts: receipt.ts };
42+
const canonical = canonicalize(payload);
43+
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(canonical));
44+
const hashHex = Buffer.from(digest).toString('hex');
45+
const sigBytes = await subtle.sign({ name: 'Ed25519' }, keyPair.privateKey, new TextEncoder().encode(hashHex));
46+
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' } };
47+
return { receipt, rawPub };
48+
}
49+
2150
const sampleReceipt = JSON.parse(
2251
fs.readFileSync(path.join(__dirname, 'fixtures', 'canonical-receipt.sample.json'), 'utf8')
2352
);
@@ -94,3 +123,28 @@ test('POST /api/verify oversized body => 413', async () => {
94123
assert.equal(res.body.ok, false);
95124
assert.equal(res.body.status, 'INVALID');
96125
});
126+
127+
128+
test('POST /api/verify can verify with injected ENS resolver', async () => {
129+
const { receipt, rawPub } = await makeRuntimeReceipt();
130+
const req = {
131+
method: 'POST',
132+
body: receipt,
133+
verifyOptions: {
134+
ens: {
135+
textResolver: async (_ens, key) => ({
136+
'cl.sig.pub': `ed25519:${rawPub}`,
137+
'cl.sig.kid': 'vC4WbcNoq2znSCiQ',
138+
'cl.sig.canonical': 'json.sorted_keys.v1',
139+
'cl.receipt.signer': 'runtime.commandlayer.eth',
140+
})[key] || null,
141+
},
142+
},
143+
};
144+
const res = makeRes();
145+
await handler(req, res);
146+
assert.equal(res.statusCode, 200);
147+
assert.equal(res.body.status, 'VERIFIED');
148+
assert.equal(res.body.public_key_source, 'ens_txt');
149+
assert.equal(res.body.ens_resolved, true);
150+
});

tests/mainnet-rpc.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
6+
const { getMainnetRpcUrl } = require('../lib/mainnetRpc');
7+
8+
test('getMainnetRpcUrl prefers ETHEREUM_RPC_URL', () => {
9+
const env = {
10+
ETHEREUM_RPC_URL: 'https://rpc-1.example',
11+
MAINNET_RPC_URL: 'https://rpc-2.example',
12+
ALCHEMY_ETHEREUM_RPC_URL: 'https://rpc-3.example',
13+
ALCHEMY_API_KEY: 'abc123',
14+
};
15+
assert.equal(getMainnetRpcUrl(env), 'https://rpc-1.example');
16+
});
17+
18+
test('getMainnetRpcUrl maps ALCHEMY_API_KEY to Alchemy HTTPS URL', () => {
19+
const env = { ALCHEMY_API_KEY: 'abc123' };
20+
assert.equal(getMainnetRpcUrl(env), 'https://eth-mainnet.g.alchemy.com/v2/abc123');
21+
});

tests/verifyReceipt-runtime.test.js

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ test('fails with key_resolution_failed when ENS resolver throws', async () => {
127127
});
128128

129129
assert.equal(out.status, 'INVALID');
130-
assert.equal(out.reason, 'Receipt is invalid, tampered, or does not match the signer key metadata.');
130+
assert.equal(out.reason, 'key_resolution_failed');
131131
assert.equal(out.debug.key_resolution_error, 'key_resolution_failed');
132132
assert.equal(out.public_key_source, 'ens_txt');
133133
});
@@ -218,3 +218,29 @@ test('multi-signature proof shape does not crash runtime verifier', async () =>
218218
const out = await verifyReceipt(receipt, { ens: { textResolver: makeTextResolver(rawPub) } });
219219
assert.equal(out.status, 'INVALID');
220220
});
221+
222+
223+
test('uses configured provider path for ENS TXT resolution when no textResolver injected', async () => {
224+
const { receipt, rawPub } = await makeRuntimeReceipt();
225+
const calls = [];
226+
const provider = {
227+
async getResolver(name) {
228+
calls.push(name);
229+
return {
230+
async getText(key) {
231+
return ({
232+
'cl.sig.pub': `ed25519:${rawPub}`,
233+
'cl.sig.kid': 'vC4WbcNoq2znSCiQ',
234+
'cl.sig.canonical': 'json.sorted_keys.v1',
235+
'cl.receipt.signer': 'runtime.commandlayer.eth',
236+
})[key] || null;
237+
},
238+
};
239+
},
240+
};
241+
242+
const out = await verifyReceipt(receipt, { ens: { provider } });
243+
assert.equal(out.status, 'VERIFIED');
244+
assert.equal(calls.length > 0, true);
245+
assert.equal(out.public_key_source, 'ens_txt');
246+
});

0 commit comments

Comments
 (0)