ocp-verify 1.2.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +89 -365
- package/package.json +32 -31
- package/src/hash.js +205 -0
- package/src/index.js +42 -0
- package/src/index.mjs +26 -0
- package/src/normalize.js +89 -0
- package/src/profiles.js +78 -0
- package/src/verify.js +141 -0
- package/LICENSE +0 -20
- package/reference-cli/README.md +0 -82
- package/reference-cli/commit.js +0 -37
- package/reference-cli/hash-browser.js +0 -67
- package/reference-cli/revoke.js +0 -148
- package/reference-cli/temporal-bounds.js +0 -164
- package/reference-cli/verify.js +0 -241
package/reference-cli/commit.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const crypto = require("crypto");
|
|
5
|
-
|
|
6
|
-
const [, , filePath, proofArg] = process.argv;
|
|
7
|
-
|
|
8
|
-
if (!filePath) {
|
|
9
|
-
console.error("Usage: ocp-commit <file> [proof.json]");
|
|
10
|
-
process.exit(1);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
if (!fs.existsSync(filePath)) {
|
|
14
|
-
console.error(`ERROR: file not found: ${filePath}`);
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const proofPath =
|
|
19
|
-
proofArg ||
|
|
20
|
-
filePath.replace(/(\.[^/.]+)?$/, ".proof.json");
|
|
21
|
-
|
|
22
|
-
const fileBytes = fs.readFileSync(filePath);
|
|
23
|
-
const hash = "0x" + crypto.createHash("sha256").update(fileBytes).digest("hex");
|
|
24
|
-
|
|
25
|
-
const proof = {
|
|
26
|
-
version: "ocp-1",
|
|
27
|
-
hash,
|
|
28
|
-
txHash: "0x0000000000000000000000000000000000000000000000000000000000000000",
|
|
29
|
-
network: "demo-local",
|
|
30
|
-
contract: "0x0000000000000000000000000000000000000000",
|
|
31
|
-
extractionRule: "demo:proof.hash"
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
fs.writeFileSync(proofPath, JSON.stringify(proof, null, 2) + "\n");
|
|
35
|
-
|
|
36
|
-
// Clean output
|
|
37
|
-
console.log(`COMMITTED: ${filePath} → ${proofPath}`);
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// OCP Reference: Browser-native digest computation
|
|
2
|
-
// Uses Web Crypto API — no dependencies, no libraries
|
|
3
|
-
// Companion to reference-cli/verify.js (Node.js implementation)
|
|
4
|
-
//
|
|
5
|
-
// Spec: docs/spec/appendix-evm-r.md
|
|
6
|
-
// This is the write-side primitive: observation → digest
|
|
7
|
-
// The digest produced here is what gets passed to record(bytes32) on-chain
|
|
8
|
-
|
|
9
|
-
"use strict";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Compute the OCP digest of a File object.
|
|
13
|
-
*
|
|
14
|
-
* Implements the commitment procedure from appendix-evm-r.md:
|
|
15
|
-
* - Serialization: raw-bytes (no encoding applied)
|
|
16
|
-
* - Hash function: sha2-256
|
|
17
|
-
* - Output: lowercase hex string, no 0x prefix
|
|
18
|
-
*
|
|
19
|
-
* @param {File} file - Any File object (browser File API)
|
|
20
|
-
* @returns {Promise<string>} SHA-256 digest as lowercase hex, no 0x prefix
|
|
21
|
-
*/
|
|
22
|
-
async function hashFile(file) {
|
|
23
|
-
const buffer = await file.arrayBuffer();
|
|
24
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
|
25
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
26
|
-
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Compute the OCP digest of an arbitrary byte array.
|
|
31
|
-
*
|
|
32
|
-
* @param {ArrayBuffer|Uint8Array} bytes - Raw bytes
|
|
33
|
-
* @returns {Promise<string>} SHA-256 digest as lowercase hex, no 0x prefix
|
|
34
|
-
*/
|
|
35
|
-
async function hashBytes(bytes) {
|
|
36
|
-
const buffer = bytes instanceof ArrayBuffer ? bytes : bytes.buffer;
|
|
37
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
|
38
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
39
|
-
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Compute the OCP digest of a UTF-8 string.
|
|
44
|
-
* Note: the string is encoded as UTF-8 before hashing.
|
|
45
|
-
* The verifier must apply the same encoding to reproduce the digest.
|
|
46
|
-
*
|
|
47
|
-
* @param {string} text - UTF-8 string
|
|
48
|
-
* @returns {Promise<string>} SHA-256 digest as lowercase hex, no 0x prefix
|
|
49
|
-
*/
|
|
50
|
-
async function hashString(text) {
|
|
51
|
-
const bytes = new TextEncoder().encode(text);
|
|
52
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
|
|
53
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
54
|
-
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Usage example:
|
|
58
|
-
//
|
|
59
|
-
// const file = document.querySelector('input[type="file"]').files[0];
|
|
60
|
-
// const digest = await hashFile(file);
|
|
61
|
-
// // digest is ready to pass to record(bytes32) on-chain
|
|
62
|
-
// // or to include in an OCP proof envelope as commitment.digest
|
|
63
|
-
|
|
64
|
-
// Node.js export (if used in a build pipeline)
|
|
65
|
-
if (typeof module !== "undefined") {
|
|
66
|
-
module.exports = { hashFile, hashBytes, hashString };
|
|
67
|
-
}
|
package/reference-cli/revoke.js
DELETED
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
// revoke.js — zero-dependency revocation helpers for OCP
|
|
2
|
-
// CommonJS, Node.js >=18
|
|
3
|
-
// Spec: docs/spec/appendix-revocation-r.md
|
|
4
|
-
|
|
5
|
-
"use strict";
|
|
6
|
-
|
|
7
|
-
const crypto = require("crypto");
|
|
8
|
-
const https = require("https");
|
|
9
|
-
|
|
10
|
-
// Deployed contract addresses
|
|
11
|
-
const REVOCATION_CONTRACTS = {
|
|
12
|
-
"eip155:84532": "0x2fa07c85439850ff6C5688d926bDa6DaEe62Db15",
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
// RPC endpoints — no API key required
|
|
16
|
-
const RPC = {
|
|
17
|
-
"eip155:84532": "https://sepolia.base.org",
|
|
18
|
-
"eip155:8453": "https://mainnet.base.org",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
// keccak256("RevocationCommitted(bytes32,bytes32,address,uint256)")
|
|
22
|
-
const REVOCATION_EVENT_TOPIC = "0xc19951599a519bc320c0f352b2f92f315e8a2368bd0efb2e5dca3b1196e76112";
|
|
23
|
-
|
|
24
|
-
// getRevocation(bytes32) selector
|
|
25
|
-
const GET_REVOCATION_SELECTOR = "0x8d4b2a4d";
|
|
26
|
-
|
|
27
|
-
function normalizeHexDigest(value, fieldName) {
|
|
28
|
-
if (typeof value !== "string") {
|
|
29
|
-
throw new TypeError(`${fieldName} must be a string`);
|
|
30
|
-
}
|
|
31
|
-
if (!/^0x[0-9a-fA-F]{64}$/.test(value)) {
|
|
32
|
-
throw new Error(`${fieldName} must be a 0x-prefixed 32-byte hex digest`);
|
|
33
|
-
}
|
|
34
|
-
return value.toLowerCase();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function stableJson(value) {
|
|
38
|
-
if (value === null || typeof value !== "object") {
|
|
39
|
-
return JSON.stringify(value);
|
|
40
|
-
}
|
|
41
|
-
if (Array.isArray(value)) {
|
|
42
|
-
return `[${value.map(stableJson).join(",")}]`;
|
|
43
|
-
}
|
|
44
|
-
const keys = Object.keys(value).sort();
|
|
45
|
-
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function rpcCall(url, method, params) {
|
|
49
|
-
return new Promise((resolve, reject) => {
|
|
50
|
-
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
|
|
51
|
-
const urlObj = new URL(url);
|
|
52
|
-
const options = {
|
|
53
|
-
hostname: urlObj.hostname,
|
|
54
|
-
path: urlObj.pathname,
|
|
55
|
-
method: "POST",
|
|
56
|
-
headers: {
|
|
57
|
-
"Content-Type": "application/json",
|
|
58
|
-
"Content-Length": Buffer.byteLength(body),
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
const req = https.request(options, (res) => {
|
|
62
|
-
let data = "";
|
|
63
|
-
res.on("data", (chunk) => { data += chunk; });
|
|
64
|
-
res.on("end", () => {
|
|
65
|
-
try {
|
|
66
|
-
const parsed = JSON.parse(data);
|
|
67
|
-
if (parsed.error) reject(new Error(`RPC error: ${parsed.error.message}`));
|
|
68
|
-
else resolve(parsed.result);
|
|
69
|
-
} catch (e) {
|
|
70
|
-
reject(new Error(`Failed to parse RPC response: ${data}`));
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
req.on("error", reject);
|
|
75
|
-
req.write(body);
|
|
76
|
-
req.end();
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function decodeRevocationRecord(hex) {
|
|
81
|
-
const data = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
82
|
-
const revocationDigest = "0x" + data.slice(0, 64);
|
|
83
|
-
const revoker = "0x" + data.slice(64, 128).slice(24);
|
|
84
|
-
const timestamp = parseInt(data.slice(128, 192), 16);
|
|
85
|
-
const exists = data.slice(192, 256) !== "0".repeat(64);
|
|
86
|
-
return { revocationDigest, revoker, timestamp, exists };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async function buildRevocationDigest(originalDigest, metadata = {}) {
|
|
90
|
-
const normalizedOriginalDigest = normalizeHexDigest(originalDigest, "originalDigest");
|
|
91
|
-
const payload = { originalDigest: normalizedOriginalDigest, ...metadata };
|
|
92
|
-
const encoded = stableJson(payload);
|
|
93
|
-
return `0x${crypto.createHash("sha256").update(encoded).digest("hex")}`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function checkRevocationStatus(originalDigest, chainId, rpcUrl, contractAddress) {
|
|
97
|
-
normalizeHexDigest(originalDigest, "originalDigest");
|
|
98
|
-
|
|
99
|
-
const resolvedRpc = rpcUrl || RPC[chainId];
|
|
100
|
-
const resolvedContract = contractAddress || REVOCATION_CONTRACTS[chainId];
|
|
101
|
-
|
|
102
|
-
if (!resolvedRpc) throw new Error(`no RPC endpoint for chain ${chainId} — pass rpcUrl explicitly`);
|
|
103
|
-
if (!resolvedContract) throw new Error(`no contract address for chain ${chainId} — pass contractAddress explicitly`);
|
|
104
|
-
|
|
105
|
-
const digestHex = originalDigest.slice(2).toLowerCase();
|
|
106
|
-
const calldata = GET_REVOCATION_SELECTOR + digestHex;
|
|
107
|
-
|
|
108
|
-
let result;
|
|
109
|
-
try {
|
|
110
|
-
result = await rpcCall(resolvedRpc, "eth_call", [
|
|
111
|
-
{ to: resolvedContract, data: calldata },
|
|
112
|
-
"latest",
|
|
113
|
-
]);
|
|
114
|
-
} catch (e) {
|
|
115
|
-
throw new Error(`RPC call failed: ${e.message}`);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (!result || result === "0x") return { status: "NOT_FOUND", record: null };
|
|
119
|
-
|
|
120
|
-
const record = decodeRevocationRecord(result);
|
|
121
|
-
if (!record.exists) return { status: "NOT_FOUND", record: null };
|
|
122
|
-
|
|
123
|
-
return { status: "REVOKED", record };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function verifyWithRevocation(originalDigest, asOfTimestamp, chainId, rpcUrl, contractAddress) {
|
|
127
|
-
normalizeHexDigest(originalDigest, "originalDigest");
|
|
128
|
-
|
|
129
|
-
if (typeof asOfTimestamp !== "number") {
|
|
130
|
-
throw new TypeError("asOfTimestamp must be a number (Unix seconds)");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const { status, record } = await checkRevocationStatus(
|
|
134
|
-
originalDigest, chainId, rpcUrl, contractAddress
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
if (status === "NOT_FOUND") return "NOT_FOUND";
|
|
138
|
-
if (record.timestamp <= asOfTimestamp) return "REVOKED";
|
|
139
|
-
return "VALID";
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
module.exports = {
|
|
143
|
-
buildRevocationDigest,
|
|
144
|
-
checkRevocationStatus,
|
|
145
|
-
verifyWithRevocation,
|
|
146
|
-
stableJson,
|
|
147
|
-
REVOCATION_EVENT_TOPIC,
|
|
148
|
-
};
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// temporal-bounds.js — zero-dependency temporal bound helper for OCP
|
|
2
|
-
// CommonJS, Node.js >=18
|
|
3
|
-
// Spec: docs/spec/appendix-temporal-bounds-r.md
|
|
4
|
-
|
|
5
|
-
"use strict";
|
|
6
|
-
|
|
7
|
-
const https = require("https");
|
|
8
|
-
|
|
9
|
-
// RPC endpoints — no API key required
|
|
10
|
-
const RPC = {
|
|
11
|
-
"eip155:1": "https://cloudflare-eth.com",
|
|
12
|
-
"eip155:8453": "https://mainnet.base.org",
|
|
13
|
-
"eip155:84532": "https://sepolia.base.org",
|
|
14
|
-
"eip155:11155111": "https://rpc.sepolia.org",
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
// Finality table — F(L) per network
|
|
18
|
-
// Source: docs/spec/appendix-temporal-bounds-r.md
|
|
19
|
-
const FINALITY = {
|
|
20
|
-
"eip155:1": {
|
|
21
|
-
safe_depth: 64,
|
|
22
|
-
finality_window_seconds: 720,
|
|
23
|
-
validator_influence_window: 12,
|
|
24
|
-
finality_model: "Casper FFG — 2 epochs",
|
|
25
|
-
},
|
|
26
|
-
"eip155:8453": {
|
|
27
|
-
safe_depth: 32,
|
|
28
|
-
finality_window_seconds: 420,
|
|
29
|
-
validator_influence_window: 2,
|
|
30
|
-
finality_model: "L2 soft finality",
|
|
31
|
-
l1_chain_id: "eip155:1",
|
|
32
|
-
},
|
|
33
|
-
"eip155:84532": {
|
|
34
|
-
safe_depth: 32,
|
|
35
|
-
finality_window_seconds: 420,
|
|
36
|
-
validator_influence_window: 2,
|
|
37
|
-
finality_model: "L2 soft finality",
|
|
38
|
-
l1_chain_id: "eip155:11155111",
|
|
39
|
-
},
|
|
40
|
-
"eip155:11155111": {
|
|
41
|
-
safe_depth: 64,
|
|
42
|
-
finality_window_seconds: 720,
|
|
43
|
-
validator_influence_window: 12,
|
|
44
|
-
finality_model: "Casper FFG — 2 epochs",
|
|
45
|
-
},
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
function rpcCall(url, method, params) {
|
|
49
|
-
return new Promise((resolve, reject) => {
|
|
50
|
-
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
|
|
51
|
-
const urlObj = new URL(url);
|
|
52
|
-
const options = {
|
|
53
|
-
hostname: urlObj.hostname,
|
|
54
|
-
path: urlObj.pathname,
|
|
55
|
-
method: "POST",
|
|
56
|
-
headers: {
|
|
57
|
-
"Content-Type": "application/json",
|
|
58
|
-
"Content-Length": Buffer.byteLength(body),
|
|
59
|
-
},
|
|
60
|
-
};
|
|
61
|
-
const req = https.request(options, (res) => {
|
|
62
|
-
let data = "";
|
|
63
|
-
res.on("data", (chunk) => { data += chunk; });
|
|
64
|
-
res.on("end", () => {
|
|
65
|
-
try {
|
|
66
|
-
const parsed = JSON.parse(data);
|
|
67
|
-
if (parsed.error) reject(new Error(`RPC error: ${parsed.error.message}`));
|
|
68
|
-
else resolve(parsed.result);
|
|
69
|
-
} catch (e) {
|
|
70
|
-
reject(new Error(`Failed to parse RPC response: ${data}`));
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
req.on("error", reject);
|
|
75
|
-
req.write(body);
|
|
76
|
-
req.end();
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function toIso8601(unixSeconds) {
|
|
81
|
-
return new Date(unixSeconds * 1000).toISOString();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function normalizeHexDigest(value, fieldName) {
|
|
85
|
-
if (typeof value !== "string") {
|
|
86
|
-
throw new TypeError(`${fieldName} must be a string`);
|
|
87
|
-
}
|
|
88
|
-
if (!/^0x[0-9a-fA-F]{64}$/.test(value)) {
|
|
89
|
-
throw new Error(`${fieldName} must be a 0x-prefixed 32-byte hex digest`);
|
|
90
|
-
}
|
|
91
|
-
return value.toLowerCase();
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function getTemporalBound(txHash, chainId, rpcUrl) {
|
|
95
|
-
if (!txHash || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) {
|
|
96
|
-
throw new Error("txHash must be a 0x-prefixed 32-byte hex string");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const finality = FINALITY[chainId];
|
|
100
|
-
if (!finality) {
|
|
101
|
-
throw new Error(`unsupported chainId: ${chainId} — add to FINALITY table`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const resolvedRpc = rpcUrl || RPC[chainId];
|
|
105
|
-
if (!resolvedRpc) {
|
|
106
|
-
throw new Error(`no RPC endpoint for chain ${chainId} — pass rpcUrl explicitly`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Step 1 — fetch transaction receipt to get block number
|
|
110
|
-
let receipt;
|
|
111
|
-
try {
|
|
112
|
-
receipt = await rpcCall(resolvedRpc, "eth_getTransactionReceipt", [txHash]);
|
|
113
|
-
} catch (e) {
|
|
114
|
-
throw new Error(`RPC call failed: ${e.message}`);
|
|
115
|
-
}
|
|
116
|
-
if (!receipt) throw new Error(`transaction not found: ${txHash}`);
|
|
117
|
-
|
|
118
|
-
const blockNumber = parseInt(receipt.blockNumber, 16);
|
|
119
|
-
|
|
120
|
-
// Step 2 — fetch block to get timestamp
|
|
121
|
-
let block;
|
|
122
|
-
try {
|
|
123
|
-
block = await rpcCall(resolvedRpc, "eth_getBlockByNumber", [receipt.blockNumber, false]);
|
|
124
|
-
} catch (e) {
|
|
125
|
-
throw new Error(`RPC call failed fetching block: ${e.message}`);
|
|
126
|
-
}
|
|
127
|
-
if (!block) throw new Error(`block not found: ${receipt.blockNumber}`);
|
|
128
|
-
|
|
129
|
-
const blockTimestamp = parseInt(block.timestamp, 16);
|
|
130
|
-
|
|
131
|
-
// Step 3 — fetch current block number to compute finality depth
|
|
132
|
-
let currentBlockHex;
|
|
133
|
-
try {
|
|
134
|
-
currentBlockHex = await rpcCall(resolvedRpc, "eth_blockNumber", []);
|
|
135
|
-
} catch (e) {
|
|
136
|
-
throw new Error(`RPC call failed fetching current block: ${e.message}`);
|
|
137
|
-
}
|
|
138
|
-
const currentBlock = parseInt(currentBlockHex, 16);
|
|
139
|
-
const depth = currentBlock - blockNumber;
|
|
140
|
-
const finalized = depth >= finality.safe_depth;
|
|
141
|
-
|
|
142
|
-
// Step 4 — compute upper bound
|
|
143
|
-
const upperBound = blockTimestamp + finality.validator_influence_window;
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
tx_hash: txHash,
|
|
147
|
-
chain_id: chainId,
|
|
148
|
-
block_number: blockNumber,
|
|
149
|
-
block_timestamp: blockTimestamp,
|
|
150
|
-
block_timestamp_iso: toIso8601(blockTimestamp),
|
|
151
|
-
finality_depth_confirmed: depth,
|
|
152
|
-
finality_depth_required: finality.safe_depth,
|
|
153
|
-
finality_model: finality.finality_model,
|
|
154
|
-
finalized,
|
|
155
|
-
validator_influence_window: finality.validator_influence_window,
|
|
156
|
-
upper_bound: upperBound,
|
|
157
|
-
upper_bound_iso: toIso8601(upperBound),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
module.exports = {
|
|
162
|
-
getTemporalBound,
|
|
163
|
-
FINALITY,
|
|
164
|
-
};
|
package/reference-cli/verify.js
DELETED
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// OCP Reference Verifier v2.1.0
|
|
4
|
-
// Implements: evm/event-log extraction rule
|
|
5
|
-
// Dependencies: zero — Node.js stdlib only (https, crypto, fs)
|
|
6
|
-
// Spec: docs/spec/appendix-evm-r.md
|
|
7
|
-
|
|
8
|
-
"use strict";
|
|
9
|
-
|
|
10
|
-
const fs = require("fs");
|
|
11
|
-
const crypto = require("crypto");
|
|
12
|
-
const https = require("https");
|
|
13
|
-
|
|
14
|
-
// RPC endpoints — no API key required
|
|
15
|
-
const RPC = {
|
|
16
|
-
"eip155:84532": "https://sepolia.base.org",
|
|
17
|
-
"eip155:8453": "https://mainnet.base.org",
|
|
18
|
-
"eip155:1": "https://cloudflare-eth.com",
|
|
19
|
-
"eip155:11155111": "https://rpc.sepolia.org",
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// Network name to CAIP-2 chain ID (proof-format-v1 compatibility)
|
|
23
|
-
const NETWORK_TO_CHAIN_ID = {
|
|
24
|
-
"base-sepolia": "eip155:84532",
|
|
25
|
-
"base": "eip155:8453",
|
|
26
|
-
"mainnet": "eip155:1",
|
|
27
|
-
"homestead": "eip155:1",
|
|
28
|
-
"sepolia": "eip155:11155111",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
// keccak256("Recorded(bytes32,address)")
|
|
32
|
-
// Confirmed from live Base Sepolia transaction logs
|
|
33
|
-
const KNOWN_EVENT_TOPIC = "0xdca60c2087041cbb12d9a57628c6cad28ecbd0437e47c7ab6c3aa6e162bf4497";
|
|
34
|
-
|
|
35
|
-
// Identity pipeline sentinel hash
|
|
36
|
-
// Canonical identity spec: ipfs://QmTst97dG8i9tFrutdetqMbVhSHqJGJaxMmPzWCcVVTWDU
|
|
37
|
-
// Confirmed independently against Dinamic.eth implementation — locked
|
|
38
|
-
const IDENTITY_PIPELINE_SENTINEL = "8116eec29078e8f57c07077d5e8080a35bde73036581df3abb93755d1b1a16ea";
|
|
39
|
-
|
|
40
|
-
function fail(message) {
|
|
41
|
-
console.error(`INVALID: ${message}`);
|
|
42
|
-
process.exit(1);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function log(message) {
|
|
46
|
-
console.log(message);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function rpcCall(url, method, params) {
|
|
50
|
-
return new Promise((resolve, reject) => {
|
|
51
|
-
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
|
|
52
|
-
const urlObj = new URL(url);
|
|
53
|
-
const options = {
|
|
54
|
-
hostname: urlObj.hostname,
|
|
55
|
-
path: urlObj.pathname,
|
|
56
|
-
method: "POST",
|
|
57
|
-
headers: {
|
|
58
|
-
"Content-Type": "application/json",
|
|
59
|
-
"Content-Length": Buffer.byteLength(body),
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
const req = https.request(options, (res) => {
|
|
63
|
-
let data = "";
|
|
64
|
-
res.on("data", (chunk) => { data += chunk; });
|
|
65
|
-
res.on("end", () => {
|
|
66
|
-
try {
|
|
67
|
-
const parsed = JSON.parse(data);
|
|
68
|
-
if (parsed.error) reject(new Error(`RPC error: ${parsed.error.message}`));
|
|
69
|
-
else resolve(parsed.result);
|
|
70
|
-
} catch (e) {
|
|
71
|
-
reject(new Error(`Failed to parse RPC response: ${data}`));
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
req.on("error", reject);
|
|
76
|
-
req.write(body);
|
|
77
|
-
req.end();
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function normalizeHash(h) {
|
|
82
|
-
if (!h) return null;
|
|
83
|
-
return h.toLowerCase().startsWith("0x") ? h.toLowerCase() : "0x" + h.toLowerCase();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function stripPrefix(h) {
|
|
87
|
-
if (!h) return null;
|
|
88
|
-
return h.toLowerCase().startsWith("0x") ? h.slice(2).toLowerCase() : h.toLowerCase();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function applyExtractionRule(receipt, eventTopic) {
|
|
92
|
-
const extracted = new Set();
|
|
93
|
-
if (!receipt.logs || !Array.isArray(receipt.logs)) return extracted;
|
|
94
|
-
for (const log of receipt.logs) {
|
|
95
|
-
if (!log.topics || log.topics.length < 2) continue;
|
|
96
|
-
if (normalizeHash(log.topics[0]) !== normalizeHash(eventTopic)) continue;
|
|
97
|
-
const digest = stripPrefix(log.topics[1]);
|
|
98
|
-
if (digest) extracted.add(digest);
|
|
99
|
-
}
|
|
100
|
-
return extracted;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function main() {
|
|
104
|
-
const [, , filePath, proofArg] = process.argv;
|
|
105
|
-
|
|
106
|
-
if (!filePath) {
|
|
107
|
-
console.error("Usage: ocp-verify <file> [proof.json]");
|
|
108
|
-
console.error("");
|
|
109
|
-
console.error("Environment:");
|
|
110
|
-
console.error(" OCP_RPC_URL=<url> Override RPC endpoint (optional)");
|
|
111
|
-
process.exit(1);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (!fs.existsSync(filePath)) fail(`file not found: ${filePath}`);
|
|
115
|
-
|
|
116
|
-
const proofPath = proofArg || filePath.replace(/(\.[^/.]+)?$/, ".proof.json");
|
|
117
|
-
if (!fs.existsSync(proofPath)) fail(`proof not found: ${proofPath}`);
|
|
118
|
-
|
|
119
|
-
const fileBytes = fs.readFileSync(filePath);
|
|
120
|
-
let proof;
|
|
121
|
-
try {
|
|
122
|
-
proof = JSON.parse(fs.readFileSync(proofPath, "utf8"));
|
|
123
|
-
} catch {
|
|
124
|
-
fail("invalid JSON in proof file");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const isEnvelope = !!proof.ocp;
|
|
128
|
-
const isV1 = proof.version === "ocp-1";
|
|
129
|
-
if (!isEnvelope && !isV1) fail("unrecognized proof format — expected ocp-1 or envelope format");
|
|
130
|
-
|
|
131
|
-
let txHash, chainId, commitmentDigest, blockHash;
|
|
132
|
-
|
|
133
|
-
if (isV1) {
|
|
134
|
-
const requiredFields = ["version", "hash", "txHash", "network", "contract", "extractionRule"];
|
|
135
|
-
for (const field of requiredFields) {
|
|
136
|
-
if (!proof[field]) fail(`missing required field: ${field}`);
|
|
137
|
-
}
|
|
138
|
-
if (!/^0x[a-f0-9]{64}$/.test(proof.hash)) fail("invalid hash format");
|
|
139
|
-
if (!/^0x[a-fA-F0-9]{64}$/.test(proof.txHash)) fail("invalid txHash format");
|
|
140
|
-
if (!/^0x[a-fA-F0-9]{40}$/.test(proof.contract)) fail("invalid contract format");
|
|
141
|
-
|
|
142
|
-
txHash = proof.txHash;
|
|
143
|
-
chainId = NETWORK_TO_CHAIN_ID[proof.network];
|
|
144
|
-
commitmentDigest = stripPrefix(proof.hash);
|
|
145
|
-
blockHash = null;
|
|
146
|
-
|
|
147
|
-
if (!chainId) fail(`unknown network: ${proof.network} — add to NETWORK_TO_CHAIN_ID`);
|
|
148
|
-
|
|
149
|
-
} else {
|
|
150
|
-
if (!proof.chain?.id) fail("missing chain.id");
|
|
151
|
-
if (!proof.chain?.namespace) fail("missing chain.namespace");
|
|
152
|
-
if (!proof.commitment?.digest) fail("missing commitment.digest");
|
|
153
|
-
if (!proof.commitment?.hash_function) fail("missing commitment.hash_function");
|
|
154
|
-
if (!proof.commitment?.serialization) fail("missing commitment.serialization");
|
|
155
|
-
if (!proof.ledger_ref?.transaction_id) fail("missing ledger_ref.transaction_id");
|
|
156
|
-
if (!proof.ledger_ref?.block_hash) fail("missing ledger_ref.block_hash");
|
|
157
|
-
if (!proof.extraction?.rule_id) fail("missing extraction.rule_id");
|
|
158
|
-
|
|
159
|
-
if (proof.commitment.hash_function !== "sha2-256")
|
|
160
|
-
fail(`unsupported hash function: ${proof.commitment.hash_function}`);
|
|
161
|
-
if (proof.commitment.serialization !== "raw-bytes")
|
|
162
|
-
fail(`unsupported serialization: ${proof.commitment.serialization}`);
|
|
163
|
-
if (!proof.extraction.rule_id.startsWith("evm/"))
|
|
164
|
-
fail(`unsupported extraction rule namespace: ${proof.extraction.rule_id}`);
|
|
165
|
-
|
|
166
|
-
txHash = proof.ledger_ref.transaction_id;
|
|
167
|
-
chainId = proof.chain.id;
|
|
168
|
-
commitmentDigest = proof.commitment.digest.toLowerCase();
|
|
169
|
-
blockHash = proof.ledger_ref.block_hash;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Step 1 — Recompute digest
|
|
173
|
-
const computedHash = crypto.createHash("sha256").update(fileBytes).digest("hex");
|
|
174
|
-
if (computedHash !== commitmentDigest) {
|
|
175
|
-
fail(`hash mismatch\n computed: ${computedHash}\n proof: ${commitmentDigest}`);
|
|
176
|
-
}
|
|
177
|
-
log(` hash MATCH ${computedHash}`);
|
|
178
|
-
|
|
179
|
-
// Step 2 — Resolve RPC
|
|
180
|
-
const rpcUrl = process.env.OCP_RPC_URL || RPC[chainId];
|
|
181
|
-
if (!rpcUrl) fail(`no RPC endpoint for chain ${chainId} — set OCP_RPC_URL`);
|
|
182
|
-
|
|
183
|
-
log(` chain ${chainId}`);
|
|
184
|
-
log(` rpc ${rpcUrl}`);
|
|
185
|
-
log(` tx ${txHash}`);
|
|
186
|
-
|
|
187
|
-
// Step 3 — Fetch receipt
|
|
188
|
-
let receipt;
|
|
189
|
-
try {
|
|
190
|
-
receipt = await rpcCall(rpcUrl, "eth_getTransactionReceipt", [txHash]);
|
|
191
|
-
} catch (e) {
|
|
192
|
-
fail(`RPC call failed: ${e.message}`);
|
|
193
|
-
}
|
|
194
|
-
if (!receipt) fail(`transaction not found: ${txHash}`);
|
|
195
|
-
|
|
196
|
-
// Step 4 — Confirm block hash (envelope only)
|
|
197
|
-
if (blockHash) {
|
|
198
|
-
const receiptBlockHash = normalizeHash(receipt.blockHash);
|
|
199
|
-
const expectedBlockHash = normalizeHash(blockHash);
|
|
200
|
-
if (receiptBlockHash !== expectedBlockHash) {
|
|
201
|
-
fail(`block hash mismatch\n receipt: ${receiptBlockHash}\n proof: ${expectedBlockHash}`);
|
|
202
|
-
}
|
|
203
|
-
log(` block MATCH ${receiptBlockHash}`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Step 5 — Apply extraction rule
|
|
207
|
-
const extracted = applyExtractionRule(receipt, KNOWN_EVENT_TOPIC);
|
|
208
|
-
if (extracted.size === 0) {
|
|
209
|
-
fail(
|
|
210
|
-
`no Recorded events found in transaction ${txHash}\n` +
|
|
211
|
-
` event topic: ${KNOWN_EVENT_TOPIC}\n` +
|
|
212
|
-
` logs found: ${receipt.logs?.length ?? 0}`
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
log(` logs found ${extracted.size} Recorded event(s)`);
|
|
216
|
-
|
|
217
|
-
// Step 6 — Confirm inclusion
|
|
218
|
-
if (!extracted.has(commitmentDigest)) {
|
|
219
|
-
fail(
|
|
220
|
-
`digest not found in transaction logs\n` +
|
|
221
|
-
` looking for: ${commitmentDigest}\n` +
|
|
222
|
-
` found: ${[...extracted].join(", ")}`
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
log(` digest MATCH ${commitmentDigest}`);
|
|
226
|
-
|
|
227
|
-
// Step 7 — Report finality
|
|
228
|
-
if (isEnvelope && proof.ledger_ref?.finality) {
|
|
229
|
-
const { depth, assertion_time_utc } = proof.ledger_ref.finality;
|
|
230
|
-
log(` finality depth=${depth} at ${assertion_time_utc}`);
|
|
231
|
-
if (depth < 3) log(` WARNING finality depth ${depth} is below recommended minimum (3)`);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
log("");
|
|
235
|
-
log("VALID");
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
main().catch((e) => {
|
|
239
|
-
console.error(`ERROR: ${e.message}`);
|
|
240
|
-
process.exit(1);
|
|
241
|
-
});
|