btcnotar 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yordan Karparov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,207 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # btcnotar
2
+
3
+ Batch Bitcoin notarization for Node.js.
4
+
5
+ `btcnotar` collects document hashes, combines them into a **Merkle tree**,
6
+ and anchors only the tree's root on the Bitcoin blockchain via a single
7
+ `OP_RETURN` transaction — automatically, on a schedule (once a day by
8
+ default). Each individual hash gets back a small **inclusion proof** that
9
+ lets anyone later prove — unambiguously, against the live chain, down to
10
+ the transaction id, block and date — that their document was notarized,
11
+ without needing the rest of the batch.
12
+
13
+ ## Why batching
14
+
15
+ Anchoring one hash per transaction is expensive and doesn't scale. Batching
16
+ thousands of hashes into one Merkle root per settlement means:
17
+
18
+ - **One on-chain transaction settles an unlimited number of documents.**
19
+ - **Cheap**: fees are paid once per batch, not per document.
20
+ - **Reversible / verifiable**: given only your original hash and its proof,
21
+ you can recompute the Merkle root and confirm it matches what's actually
22
+ on-chain — no trust in the notary's database required.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ npm install btcnotar
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```js
33
+ import { BTCNotar, FileStore } from "btcnotar";
34
+
35
+ const notary = new BTCNotar({
36
+ wif: process.env.BTCNOTAR_WIF, // Bitcoin private key (WIF) that funds/signs anchor txs
37
+ network: "mainnet", // or "testnet"
38
+ store: new FileStore({ filePath: "./.btcnotar/store.json" }),
39
+ intervalMs: 24 * 60 * 60 * 1000, // settle once a day (default)
40
+ });
41
+
42
+ // 1. Queue hashes as documents come in (e.g. sha256 of a file)
43
+ await notary.addHash("2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881");
44
+
45
+ // 2. Start automatic daily settlement (builds the Merkle tree + broadcasts
46
+ // one OP_RETURN transaction anchoring the root)
47
+ notary.start();
48
+
49
+ // ... later, anywhere, even in a different process that only has the hash ...
50
+
51
+ const proof = await notary.getProof(hash);
52
+ // { hash, merkleRoot, proof, txid, batchId, blockHeight, blockHash, blockTime, ... }
53
+
54
+ const result = await notary.verify(hash);
55
+ // {
56
+ // valid: true,
57
+ // confirmed: true,
58
+ // txid: "…",
59
+ // blockHeight: 912345,
60
+ // blockHash: "…",
61
+ // date: "2026-09-10T00:00:00.000Z",
62
+ // merkleRoot: "…",
63
+ // }
64
+ ```
65
+
66
+ `verify()` does two independent checks before it reports `valid: true`:
67
+
68
+ 1. **Cryptographic**: recomputes the Merkle root from the hash + its proof
69
+ and checks it equals the stored root.
70
+ 2. **On-chain**: fetches the anchoring transaction live from the blockchain
71
+ and confirms its `OP_RETURN` output actually carries that same root.
72
+
73
+ Only when both agree is the hash reported as anchored — together with the
74
+ transaction id, block hash/height and date, so "is this document on the
75
+ blockchain, and when/where" has one unambiguous answer.
76
+
77
+ ## Free public server (no key, no bitcoin)
78
+
79
+ Don't want to run your own notary? Send hashes to the free public BTCNotar server
80
+ (`https://proofofexistance-production.up.railway.app`, used by default).
81
+ The server batches them and pays the fee (one transaction per batch).
82
+ You verify the result yourself against the blockchain, so you don't have to trust the server.
83
+
84
+ ```js
85
+ import { BTCNotarClient, sha256Hex } from "btcnotar";
86
+
87
+ const client = new BTCNotarClient(); // or { serverUrl: "https://your-own-server" }
88
+
89
+ const hash = sha256Hex(fs.readFileSync("contract.pdf"));
90
+ await client.submit(hash); // { status: "pending", nextSettleAt: "..." }
91
+
92
+ // after the next settlement (see nextSettleAt):
93
+ await client.verify(hash); // { valid: true, confirmed: true, txid, blockHeight, date }
94
+ ```
95
+
96
+ - `submit(hash)` — add the hash to the server queue.
97
+ - `getProof(hash)` — proof record, `{ status: "pending" }` while waiting, or `null`.
98
+ - `verify(hash)` — gets the proof from the server and checks it on-chain (mempool.space by default, or pass your own `provider`).
99
+
100
+ From the command line:
101
+
102
+ ```
103
+ npx btcnotar submit "any text or a sha256 hash"
104
+ npx btcnotar check "any text or a sha256 hash"
105
+ # own server: add --server https://your-own-server
106
+ ```
107
+
108
+ ## API
109
+
110
+ ### `new BTCNotar(options)`
111
+
112
+ | option | required | default | description |
113
+ |---------------|----------|---------------------|-------------|
114
+ | `wif` | yes | — | Bitcoin private key (WIF) used to fund and sign anchor transactions |
115
+ | `network` | no | `"mainnet"` | `"mainnet"` or `"testnet"` |
116
+ | `store` | no | `new MemoryStore()` | pending hashes + settled batches storage (see below) |
117
+ | `provider` | no | `new MempoolProvider()` | chain data source (UTXOs, fees, broadcast, tx lookup) |
118
+ | `feeRateSatVb`| no | fetched live | fixed fee rate override (sat/vB) |
119
+ | `intervalMs` | no | `86400000` (24h) | used by `start()`: a batch is anchored once the oldest pending hash has waited this long (no tx while the queue is empty) |
120
+
121
+ ### Instance methods
122
+
123
+ - `addHash(hashHex)` — queue a hex-encoded hash for the next settlement.
124
+ - `pendingCount()` — number of hashes waiting to be settled.
125
+ - `settle()` — build a Merkle tree over everything pending and anchor its
126
+ root in one `OP_RETURN` transaction right now. Returns the batch record
127
+ (`{ batchId, merkleRoot, txid, hashes, proofs, ... }`), or `null` if
128
+ nothing was pending.
129
+ - `start({ intervalMs })` / `stop()` — automatic settlement: checks every minute and anchors when the oldest pending hash is `intervalMs` old. Survives restarts (uses the stored `addedAt`).
130
+ - `settleIfDue()` — the check `start()` runs; anchors only if something has waited `intervalMs`.
131
+ - `getStatus(hash)` — `{ status: "anchored", ...proof }`, `{ status: "pending", addedAt, nextSettleAt }` or `null`.
132
+ - `anchorNow(hash)` — anchor one hash immediately (together with anything already queued) and return its proof. If the hash is already anchored, returns the existing proof without a new transaction.
133
+ - `getProof(hashHex)` — look up the stored proof + anchor info for a hash.
134
+ - `verify(hashHex)` — cryptographically and on-chain verification (see above).
135
+ - `refreshStatus(batchId)` — re-fetch a batch's transaction and update its
136
+ confirmation / block info.
137
+
138
+ ### `MerkleTree`
139
+
140
+ The underlying Merkle tree, usable standalone:
141
+
142
+ ```js
143
+ import { MerkleTree } from "btcnotar";
144
+
145
+ const tree = new MerkleTree([hashA, hashB, hashC]);
146
+ tree.root; // hex string
147
+ const proof = tree.getProof(0); // inclusion proof for hashA
148
+ MerkleTree.verifyProof(hashA, proof, tree.root); // true
149
+ ```
150
+
151
+ Leaves are domain-separated from internal nodes (distinct hash prefixes),
152
+ and an unpaired node at the end of a level is carried up unchanged rather
153
+ than duplicated — this avoids both the classic leaf/node confusion forgery
154
+ and the Bitcoin-style duplicate-leaf ambiguity (CVE-2012-2459).
155
+
156
+ ### Storage
157
+
158
+ `MemoryStore` (default, non-persistent) and `FileStore` (JSON file on disk)
159
+ are included and share the same interface: `addPending`, `listPending`,
160
+ `clearPending`, `saveBatch`, `getBatch`, `findBatchByHash`, `listBatches`.
161
+ Implement the same interface against a real database for multi-process or
162
+ production use.
163
+
164
+ ### Chain provider
165
+
166
+ `MempoolProvider` talks to the public [mempool.space](https://mempool.space)
167
+ REST API for UTXOs, fee estimation, broadcasting and transaction lookup.
168
+ Supply your own object implementing `fetchUtxos`, `fetchFeeRateSatVb`,
169
+ `broadcastTx`, `fetchTransaction` to use a different backend (e.g. a local
170
+ `bitcoind` or Electrum server).
171
+
172
+ ## CLI
173
+
174
+ The package also installs a `btcnotar` command:
175
+
176
+ ```bash
177
+ export BTCNOTAR_WIF=... # required
178
+ export BTCNOTAR_NETWORK=mainnet
179
+ export BTCNOTAR_STORE=./.btcnotar/store.json
180
+
181
+ npx btcnotar add <hash>
182
+ npx btcnotar settle
183
+ npx btcnotar proof <hash>
184
+ npx btcnotar verify <hash>
185
+ npx btcnotar address
186
+ ```
187
+
188
+ `btcnotar settle` is handy if you'd rather trigger settlement from an
189
+ external scheduler (system cron, a serverless cron trigger) instead of the
190
+ built-in `start()` interval.
191
+
192
+ ## How anchoring works on-chain
193
+
194
+ Each settlement broadcasts one transaction with a zero-value `OP_RETURN`
195
+ output carrying:
196
+
197
+ ```
198
+ "BTN1" (4 bytes, protocol magic) || Merkle root (32 bytes)
199
+ ```
200
+
201
+ 36 bytes total, well within Bitcoin's 80-byte `OP_RETURN` limit. The magic
202
+ prefix lets `verify()` recognize a BTCNotar anchor (and reject unrelated
203
+ `OP_RETURN` data) before trusting its contents as a Merkle root.
204
+
205
+ ## License
206
+
207
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
3
+ import { BTCNotar, BTCNotarClient, sha256Hex } from "../index.js";
4
+ import { FileStore } from "../src/store.js";
5
+
6
+ function usage() {
7
+ console.log(`btcnotar - batch Bitcoin notarization CLI
8
+
9
+ Free public server (no key, no bitcoin needed):
10
+ btcnotar submit <hash|text> Send a hash to the server queue (text is hashed with SHA-256)
11
+ btcnotar check <hash|text> Get the proof from the server and verify it on the blockchain
12
+
13
+ Own notary (needs BTCNOTAR_WIF and a funded address):
14
+ btcnotar add <hash> Queue a document hash for the next settlement
15
+ btcnotar settle Settle all pending hashes now (build Merkle tree + anchor on-chain)
16
+ btcnotar proof <hash> Print the stored proof for a hash
17
+ btcnotar verify <hash> Verify a hash against the live blockchain
18
+ btcnotar address Print the notary's Bitcoin address
19
+
20
+ Environment variables:
21
+ BTCNOTAR_SERVER Server for submit/check (or --server <url>). Default: the free public server
22
+ BTCNOTAR_WIF Bitcoin private key (WIF) used to fund/sign anchor transactions [required]
23
+ BTCNOTAR_NETWORK "mainnet" or "testnet" (default: mainnet)
24
+ BTCNOTAR_STORE Path to the JSON store file (default: ./.btcnotar/store.json)
25
+ `);
26
+ }
27
+
28
+ function buildNotary() {
29
+ const wif = process.env.BTCNOTAR_WIF;
30
+ if (!wif) {
31
+ console.error("Missing BTCNOTAR_WIF environment variable.");
32
+ process.exit(1);
33
+ }
34
+ const network = process.env.BTCNOTAR_NETWORK || "mainnet";
35
+ const filePath = process.env.BTCNOTAR_STORE || "./.btcnotar/store.json";
36
+ return new BTCNotar({ wif, network, store: new FileStore({ filePath }) });
37
+ }
38
+
39
+ function toHash(input) {
40
+ if (!input) throw new Error("give a hash or a text");
41
+ return /^[0-9a-fA-F]{64}$/.test(input) ? input.toLowerCase() : sha256Hex(input);
42
+ }
43
+
44
+ function buildClient(args) {
45
+ const i = args.indexOf("--server");
46
+ const serverUrl = (i >= 0 ? args[i + 1] : process.env.BTCNOTAR_SERVER) || undefined; // undefined = public server
47
+ if (i >= 0) args.splice(i, 2);
48
+ return new BTCNotarClient({ serverUrl, network: process.env.BTCNOTAR_NETWORK || "mainnet" });
49
+ }
50
+
51
+ async function main() {
52
+ const args = process.argv.slice(2);
53
+ const cmd = args[0];
54
+
55
+ if (!cmd || cmd === "-h" || cmd === "--help") return usage();
56
+
57
+ if (cmd === "submit" || cmd === "check") {
58
+ const client = buildClient(args);
59
+ const input = args.slice(1).join(" ");
60
+ const hash = toHash(input);
61
+ if (hash !== input.toLowerCase()) console.log(`SHA-256: ${hash}`);
62
+ const out = cmd === "submit" ? await client.submit(hash) : await client.verify(hash);
63
+ console.log(JSON.stringify(out, null, 2));
64
+ if (cmd === "check") process.exit(out.valid ? 0 : 1);
65
+ return;
66
+ }
67
+
68
+ const arg = args[1];
69
+
70
+ if (cmd === "address") {
71
+ const notary = buildNotary();
72
+ console.log(notary.address);
73
+ return;
74
+ }
75
+
76
+ if (cmd === "add") {
77
+ if (!arg) throw new Error("usage: btcnotar add <hash>");
78
+ const notary = buildNotary();
79
+ const entry = await notary.addHash(arg);
80
+ console.log(JSON.stringify(entry, null, 2));
81
+ return;
82
+ }
83
+
84
+ if (cmd === "settle") {
85
+ const notary = buildNotary();
86
+ const batch = await notary.settle();
87
+ console.log(batch ? JSON.stringify(batch, null, 2) : "Nothing pending.");
88
+ return;
89
+ }
90
+
91
+ if (cmd === "proof") {
92
+ if (!arg) throw new Error("usage: btcnotar proof <hash>");
93
+ const notary = buildNotary();
94
+ const proof = await notary.getProof(arg);
95
+ console.log(proof ? JSON.stringify(proof, null, 2) : "No proof found for that hash.");
96
+ return;
97
+ }
98
+
99
+ if (cmd === "verify") {
100
+ if (!arg) throw new Error("usage: btcnotar verify <hash>");
101
+ const notary = buildNotary();
102
+ const result = await notary.verify(arg);
103
+ console.log(JSON.stringify(result, null, 2));
104
+ process.exit(result.valid ? 0 : 1);
105
+ return;
106
+ }
107
+
108
+ console.error(`Unknown command: ${cmd}`);
109
+ usage();
110
+ process.exit(1);
111
+ }
112
+
113
+ main().catch((err) => {
114
+ console.error(err.message || err);
115
+ process.exit(1);
116
+ });
package/index.js ADDED
@@ -0,0 +1,15 @@
1
+ export { BTCNotar } from "./src/notary.js";
2
+ export { BTCNotarClient, sha256Hex, DEFAULT_SERVER_URL } from "./src/client.js";
3
+ export { verifyProof } from "./src/verify.js";
4
+ export { MerkleTree } from "./src/merkle.js";
5
+ export { MemoryStore, FileStore } from "./src/store.js";
6
+ export {
7
+ MempoolProvider,
8
+ createOpReturnTx,
9
+ buildOpReturnScript,
10
+ encodeOpReturnPayload,
11
+ decodeOpReturnPayload,
12
+ extractOpReturnData,
13
+ MAGIC,
14
+ OP_RETURN_MAX_BYTES,
15
+ } from "./src/bitcoin.js";
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
- {
2
- "name": "btcnotar",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "btcnotar",
3
+ "version": "0.2.0",
4
+ "description": "Batch Bitcoin notarization: aggregate document hashes into a Merkle tree, anchor the root on-chain via OP_RETURN on a schedule, and generate per-hash proofs that can be verified independently against the live blockchain (transaction, block and date).",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "bin": {
11
+ "btcnotar": "./bin/cli.js"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "src",
16
+ "bin",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test"
25
+ },
26
+ "keywords": [
27
+ "bitcoin",
28
+ "merkle-tree",
29
+ "notarization",
30
+ "timestamping",
31
+ "op_return",
32
+ "proof-of-existence",
33
+ "blockchain"
34
+ ],
35
+ "author": "Yordan Karparov",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/yordank/BTCNotar.git",
40
+ "directory": "packages/btcnotar"
41
+ },
42
+ "dependencies": {
43
+ "axios": "^1.13.2",
44
+ "bitcoinjs-lib": "^7.0.1",
45
+ "dotenv": "^17.2.3",
46
+ "ecpair": "^3.0.0",
47
+ "tiny-secp256k1": "^2.2.4"
48
+ }
49
+ }
package/src/bitcoin.js ADDED
@@ -0,0 +1,133 @@
1
+ import axios from "axios";
2
+ import * as bitcoin from "bitcoinjs-lib";
3
+
4
+ // Protocol magic so an OP_RETURN payload can be recognized as a BTCNotar
5
+ // anchor (as opposed to some other protocol's data) before trusting its
6
+ // contents as a Merkle root. "BTN1" = format version 1.
7
+ export const MAGIC = Buffer.from("BTN1", "ascii");
8
+ export const OP_RETURN_MAX_BYTES = 80;
9
+
10
+ /** Pack a 32-byte Merkle root (hex) into the on-chain OP_RETURN payload. */
11
+ export function encodeOpReturnPayload(merkleRootHex) {
12
+ const root = Buffer.from(merkleRootHex, "hex");
13
+ if (root.length !== 32) throw new Error("merkle root must be 32 bytes");
14
+ const payload = Buffer.concat([MAGIC, root]);
15
+ if (payload.length > OP_RETURN_MAX_BYTES) throw new Error("OP_RETURN payload too large");
16
+ return payload;
17
+ }
18
+
19
+ /** Inverse of encodeOpReturnPayload. Returns null if the magic doesn't match. */
20
+ export function decodeOpReturnPayload(payload) {
21
+ if (!Buffer.isBuffer(payload) || payload.length !== MAGIC.length + 32) return null;
22
+ if (!payload.subarray(0, MAGIC.length).equals(MAGIC)) return null;
23
+ return payload.subarray(MAGIC.length).toString("hex");
24
+ }
25
+
26
+ export function buildOpReturnScript(dataBuffer) {
27
+ if (dataBuffer.length > OP_RETURN_MAX_BYTES) throw new Error("OP_RETURN > 80 bytes");
28
+ return bitcoin.script.compile([bitcoin.opcodes.OP_RETURN, dataBuffer]);
29
+ }
30
+
31
+ /**
32
+ * Build, sign and serialize a single-input OP_RETURN transaction spending
33
+ * one P2WPKH UTXO, with the (optional) change sent back to the same address.
34
+ */
35
+ export function createOpReturnTx({ keyPair, fromAddress, utxo, dataBuffer, feeSat, network }) {
36
+ const p2wpkh = bitcoin.payments.p2wpkh({ pubkey: keyPair.publicKey, network });
37
+ const psbt = new bitcoin.Psbt({ network });
38
+
39
+ psbt.addInput({
40
+ hash: utxo.txid,
41
+ index: utxo.vout,
42
+ witnessUtxo: { script: p2wpkh.output, value: BigInt(utxo.value) },
43
+ });
44
+
45
+ psbt.addOutput({ script: buildOpReturnScript(dataBuffer), value: 0n });
46
+
47
+ const DUST_LIMIT = 1000n;
48
+ let change = BigInt(utxo.value) - BigInt(feeSat);
49
+ if (change <= 0n) throw new Error("Insufficient funds for fee");
50
+ if (change < DUST_LIMIT) change = 0n;
51
+ if (change > 0n) psbt.addOutput({ address: fromAddress, value: change });
52
+
53
+ psbt.signAllInputs(keyPair);
54
+ psbt.finalizeAllInputs();
55
+
56
+ const tx = psbt.extractTransaction();
57
+ return { rawHex: tx.toHex(), txid: tx.getId() };
58
+ }
59
+
60
+ /**
61
+ * Given a fetched transaction (as returned by a provider's
62
+ * fetchTransaction), locate its OP_RETURN output and decode a BTCNotar
63
+ * Merkle root from it, if present. This is what lets verification check
64
+ * the *actual* on-chain data rather than trusting a locally stored copy.
65
+ */
66
+ export function extractOpReturnData(tx) {
67
+ for (const out of tx.vout || []) {
68
+ const scriptHex = out.scriptpubkey;
69
+ if (!scriptHex || !scriptHex.startsWith("6a")) continue; // 0x6a == OP_RETURN
70
+ const decompiled = bitcoin.script.decompile(Buffer.from(scriptHex, "hex"));
71
+ const data = (decompiled || []).find((el) => Buffer.isBuffer(el));
72
+ if (!data) continue;
73
+ const root = decodeOpReturnPayload(data);
74
+ if (root) return root;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Chain data provider backed by the public mempool.space REST API.
81
+ * Swap for a different implementation (e.g. a local bitcoind/Electrum
82
+ * client) as long as it exposes the same four methods.
83
+ */
84
+ export class MempoolProvider {
85
+ constructor({ network = "mainnet", apiBase } = {}) {
86
+ this.network = network;
87
+ this.apiBase = apiBase || (network === "mainnet" ? "https://mempool.space/api" : `https://mempool.space/${network}/api`);
88
+ }
89
+
90
+ async fetchUtxos(address) {
91
+ const res = await axios.get(`${this.apiBase}/address/${address}/utxo`);
92
+ return res.data;
93
+ }
94
+
95
+ async fetchFeeRateSatVb() {
96
+ try {
97
+ const res = await axios.get(`${this.apiBase}/v1/fees/recommended`);
98
+ return Number(res.data?.halfHourFee ?? 10);
99
+ } catch {
100
+ return 10;
101
+ }
102
+ }
103
+
104
+ async broadcastTx(rawHex) {
105
+ const res = await axios.post(`${this.apiBase}/tx`, rawHex, {
106
+ headers: { "Content-Type": "text/plain" },
107
+ });
108
+ return res.data; // txid
109
+ }
110
+
111
+ /**
112
+ * Normalized transaction lookup: { txid, confirmed, blockHeight,
113
+ * blockHash, blockTime, vout: [{ scriptpubkey, value }] }.
114
+ * Returns null if the transaction is not (yet) known to the indexer.
115
+ */
116
+ async fetchTransaction(txid) {
117
+ try {
118
+ const res = await axios.get(`${this.apiBase}/tx/${txid}`);
119
+ const tx = res.data;
120
+ return {
121
+ txid: tx.txid,
122
+ confirmed: !!tx.status?.confirmed,
123
+ blockHeight: tx.status?.block_height ?? null,
124
+ blockHash: tx.status?.block_hash ?? null,
125
+ blockTime: tx.status?.block_time ?? null,
126
+ vout: (tx.vout || []).map((o) => ({ scriptpubkey: o.scriptpubkey, value: o.value })),
127
+ };
128
+ } catch (e) {
129
+ if (e?.response?.status === 404) return null;
130
+ throw e;
131
+ }
132
+ }
133
+ }
package/src/client.js ADDED
@@ -0,0 +1,68 @@
1
+ import crypto from "node:crypto";
2
+ import axios from "axios";
3
+ import { MempoolProvider } from "./bitcoin.js";
4
+ import { verifyProof } from "./verify.js";
5
+
6
+ /** SHA-256 of a string (UTF-8) or Buffer, as hex. */
7
+ export function sha256Hex(data) {
8
+ return crypto.createHash("sha256").update(data).digest("hex");
9
+ }
10
+
11
+ /** The free public BTCNotar server (used when no serverUrl is given). */
12
+ export const DEFAULT_SERVER_URL = "https://proofofexistance-production.up.railway.app";
13
+
14
+ function checkHash(hashHex) {
15
+ const hash = String(hashHex || "").trim().toLowerCase();
16
+ if (!/^[0-9a-f]{64}$/.test(hash)) throw new Error(`hash must be 64 hex chars (SHA-256), got "${hashHex}"`);
17
+ return hash;
18
+ }
19
+
20
+ /**
21
+ * Client for a public BTCNotar server. No private key and no bitcoin needed:
22
+ * the server anchors queued hashes on-chain and pays the fee.
23
+ *
24
+ * const client = new BTCNotarClient(); // free public server by default
25
+ * await client.submit(sha256Hex("my document")); // queued
26
+ * ...after the next settlement...
27
+ * await client.verify(hash); // { valid, confirmed, txid, blockHeight, date }
28
+ *
29
+ * verify() does not trust the server: it takes the proof from the server and
30
+ * checks it against the blockchain itself (via mempool.space by default).
31
+ */
32
+ export class BTCNotarClient {
33
+ constructor({ serverUrl = DEFAULT_SERVER_URL, network = "mainnet", provider, timeoutMs = 20_000 } = {}) {
34
+ this.serverUrl = serverUrl.replace(/\/+$/, "");
35
+ this.provider = provider || new MempoolProvider({ network });
36
+ this.http = axios.create({ baseURL: this.serverUrl, timeout: timeoutMs, validateStatus: () => true });
37
+ }
38
+
39
+ /** Send a hash to the server's queue. Returns { hash, addedAt, nextSettleAt, ... }. */
40
+ async submit(hashHex) {
41
+ const hash = checkHash(hashHex);
42
+ const res = await this.http.post("/api/notary/hash", { hashHex: hash });
43
+ if (res.status >= 400) throw new Error(`server error ${res.status}: ${res.data?.error || res.statusText}`);
44
+ return res.data;
45
+ }
46
+
47
+ /**
48
+ * Ask the server for the proof.
49
+ * Returns the proof record, { status: "pending", ... } while waiting, or null if the server doesn't know the hash.
50
+ */
51
+ async getProof(hashHex) {
52
+ const hash = checkHash(hashHex);
53
+ const res = await this.http.get(`/api/notary/proof/${hash}`);
54
+ if (res.status === 404) return null;
55
+ if (res.status === 202) return { status: "pending", ...res.data };
56
+ if (res.status >= 400) throw new Error(`server error ${res.status}: ${res.data?.error || res.statusText}`);
57
+ return res.data;
58
+ }
59
+
60
+ /** Get the proof from the server and verify it against the blockchain yourself. */
61
+ async verify(hashHex) {
62
+ const hash = checkHash(hashHex);
63
+ const record = await this.getProof(hash);
64
+ if (!record) return { valid: false, reason: "unknown-hash" };
65
+ if (record.status === "pending") return { valid: false, reason: "pending", nextSettleAt: record.nextSettleAt ?? null };
66
+ return verifyProof(hash, record, this.provider);
67
+ }
68
+ }
package/src/merkle.js ADDED
@@ -0,0 +1,122 @@
1
+ import crypto from "node:crypto";
2
+
3
+ // Domain-separation prefixes so a leaf hash can never be replayed as an
4
+ // internal node (and vice versa) when reconstructing a root from a proof.
5
+ const LEAF_PREFIX = Buffer.from([0x00]);
6
+ const NODE_PREFIX = Buffer.from([0x01]);
7
+
8
+ function sha256(buf) {
9
+ return crypto.createHash("sha256").update(buf).digest();
10
+ }
11
+
12
+ function toBuffer(hashHex) {
13
+ if (typeof hashHex !== "string" || !/^[0-9a-fA-F]+$/.test(hashHex) || hashHex.length % 2 !== 0) {
14
+ throw new Error(`Invalid hash: expected a hex string, got "${hashHex}"`);
15
+ }
16
+ return Buffer.from(hashHex, "hex");
17
+ }
18
+
19
+ function hashLeaf(hashHex) {
20
+ return sha256(Buffer.concat([LEAF_PREFIX, toBuffer(hashHex)]));
21
+ }
22
+
23
+ function hashNode(left, right) {
24
+ return sha256(Buffer.concat([NODE_PREFIX, left, right]));
25
+ }
26
+
27
+ /**
28
+ * A Merkle tree over a list of document hashes.
29
+ *
30
+ * - Leaves are domain-separated (0x00 prefix) so they can't collide with
31
+ * internal nodes (0x01 prefix) — this prevents the classic
32
+ * leaf/node-confusion forgery against naive Merkle proofs.
33
+ * - An unpaired node at the end of a level is carried up unchanged
34
+ * (no duplication), which avoids the CVE-2012-2459-style duplicate-leaf
35
+ * ambiguity that affects Bitcoin's own block Merkle tree.
36
+ */
37
+ export class MerkleTree {
38
+ /** @param {string[]} hashesHex - document hashes (hex-encoded) to anchor */
39
+ constructor(hashesHex) {
40
+ if (!Array.isArray(hashesHex) || hashesHex.length === 0) {
41
+ throw new Error("MerkleTree requires a non-empty array of hex hashes");
42
+ }
43
+ this.leaves = hashesHex.map((h) => h.toLowerCase());
44
+ this.layers = MerkleTree.buildLayers(this.leaves.map(hashLeaf));
45
+ }
46
+
47
+ static buildLayers(leafBuffers) {
48
+ const layers = [leafBuffers];
49
+ let current = leafBuffers;
50
+ while (current.length > 1) {
51
+ const next = [];
52
+ for (let i = 0; i < current.length; i += 2) {
53
+ if (i + 1 < current.length) {
54
+ next.push(hashNode(current[i], current[i + 1]));
55
+ } else {
56
+ next.push(current[i]); // odd one out, carried up untouched
57
+ }
58
+ }
59
+ layers.push(next);
60
+ current = next;
61
+ }
62
+ return layers;
63
+ }
64
+
65
+ /** Merkle root, hex-encoded. */
66
+ get root() {
67
+ const top = this.layers[this.layers.length - 1];
68
+ return top[0].toString("hex");
69
+ }
70
+
71
+ /**
72
+ * Inclusion proof for the leaf at `index`: an ordered list of sibling
73
+ * steps needed to recompute the root from that leaf's hash.
74
+ * @returns {{position: "left"|"right", hash: string}[]}
75
+ */
76
+ getProof(index) {
77
+ if (!Number.isInteger(index) || index < 0 || index >= this.leaves.length) {
78
+ throw new Error(`index out of range: ${index}`);
79
+ }
80
+ const proof = [];
81
+ let idx = index;
82
+ for (let level = 0; level < this.layers.length - 1; level++) {
83
+ const layer = this.layers[level];
84
+ const isRightNode = idx % 2 === 1;
85
+ const siblingIdx = isRightNode ? idx - 1 : idx + 1;
86
+ if (siblingIdx < layer.length) {
87
+ proof.push({
88
+ position: isRightNode ? "left" : "right",
89
+ hash: layer[siblingIdx].toString("hex"),
90
+ });
91
+ }
92
+ idx = Math.floor(idx / 2);
93
+ }
94
+ return proof;
95
+ }
96
+
97
+ /** Proof for the first leaf equal to `hashHex`. Throws if not found. */
98
+ getProofForHash(hashHex) {
99
+ const index = this.leaves.indexOf(hashHex.toLowerCase());
100
+ if (index === -1) throw new Error(`hash not found in this tree: ${hashHex}`);
101
+ return this.getProof(index);
102
+ }
103
+
104
+ /**
105
+ * Recompute a root from a leaf hash + proof and compare it to `rootHex`.
106
+ * This is the reversibility primitive: anyone holding only the original
107
+ * document hash and its proof can independently verify inclusion,
108
+ * without needing the full batch.
109
+ */
110
+ static verifyProof(hashHex, proof, rootHex) {
111
+ try {
112
+ let node = hashLeaf(hashHex);
113
+ for (const step of proof) {
114
+ const sibling = toBuffer(step.hash);
115
+ node = step.position === "left" ? hashNode(sibling, node) : hashNode(node, sibling);
116
+ }
117
+ return node.toString("hex") === rootHex.toLowerCase();
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+ }
package/src/notary.js ADDED
@@ -0,0 +1,248 @@
1
+ import crypto from "node:crypto";
2
+ import * as bitcoin from "bitcoinjs-lib";
3
+ import * as ecc from "tiny-secp256k1";
4
+ import { ECPairFactory } from "ecpair";
5
+ import { MerkleTree } from "./merkle.js";
6
+ import { MempoolProvider, createOpReturnTx, encodeOpReturnPayload } from "./bitcoin.js";
7
+ import { verifyProof } from "./verify.js";
8
+ import { MemoryStore } from "./store.js";
9
+
10
+ bitcoin.initEccLib(ecc);
11
+ const ECPair = ECPairFactory(ecc);
12
+
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ // Size of our tx: 1 P2WPKH input + OP_RETURN (36 bytes) + change ≈ 157 vB.
15
+ const ANCHOR_TX_VBYTES = 160;
16
+
17
+ function isHex(s) {
18
+ return typeof s === "string" && /^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0;
19
+ }
20
+
21
+ /**
22
+ * BTCNotar batches document hashes, anchors each batch's Merkle root to
23
+ * Bitcoin via a single OP_RETURN transaction, and hands back per-hash
24
+ * proofs that can be independently and unambiguously verified later
25
+ * against the live chain (which transaction, which block, which date).
26
+ *
27
+ * Typical usage:
28
+ * const notary = new BTCNotar({ wif, network: "mainnet", store });
29
+ * await notary.addHash(sha256HexOfSomeDocument);
30
+ * notary.start(); // settle once every 24h automatically
31
+ * ...
32
+ * const proof = await notary.getProof(hash);
33
+ * const result = await notary.verify(hash); // { valid, confirmed, txid, blockHeight, date, ... }
34
+ */
35
+ export class BTCNotar {
36
+ constructor({ wif, network = "mainnet", store, provider, feeRateSatVb, intervalMs = DAY_MS } = {}) {
37
+ if (!wif) throw new Error("wif (private key, WIF format) is required");
38
+ this.network = network === "mainnet" ? bitcoin.networks.bitcoin : bitcoin.networks.testnet;
39
+ this.keyPair = ECPair.fromWIF(wif, this.network);
40
+ this.address = bitcoin.payments.p2wpkh({ pubkey: this.keyPair.publicKey, network: this.network }).address;
41
+ this.store = store || new MemoryStore();
42
+ this.provider = provider || new MempoolProvider({ network });
43
+ this.feeRateSatVb = feeRateSatVb || null;
44
+ this.intervalMs = intervalMs;
45
+ this._timer = null;
46
+ }
47
+
48
+ /** Queue a document hash for the next settlement batch. */
49
+ async addHash(hashHex) {
50
+ if (!isHex(hashHex)) throw new Error(`hash must be a hex string, got "${hashHex}"`);
51
+ const hash = hashHex.toLowerCase();
52
+ const queued = (await this.store.listPending()).find((p) => p.hash === hash);
53
+ if (queued) return queued; // already waiting: don't add it twice
54
+ const entry = { id: crypto.randomUUID(), hash, addedAt: new Date().toISOString() };
55
+ await this.store.addPending(entry);
56
+ return entry;
57
+ }
58
+
59
+ /** How many hashes are waiting for the next settlement. */
60
+ async pendingCount() {
61
+ return (await this.store.listPending()).length;
62
+ }
63
+
64
+ /**
65
+ * Build a Merkle tree over all currently pending hashes and anchor its
66
+ * root on-chain in one OP_RETURN transaction. Returns the settled batch
67
+ * (including a proof per hash), or null if there was nothing pending.
68
+ */
69
+ settle() {
70
+ // Run settlements one after another (timer + manual call at the same time
71
+ // would otherwise try to spend the same UTXO twice).
72
+ const run = (this._settleChain || Promise.resolve()).then(() => this._settle());
73
+ this._settleChain = run.catch(() => {});
74
+ return run;
75
+ }
76
+
77
+ async _settle() {
78
+ const pending = await this.store.listPending();
79
+ if (!pending.length) return null;
80
+
81
+ const hashes = pending.map((p) => p.hash);
82
+ const tree = new MerkleTree(hashes);
83
+ const merkleRoot = tree.root;
84
+
85
+ const utxos = await this.provider.fetchUtxos(this.address);
86
+ if (!utxos.length) throw new Error(`No UTXOs available. Fund address: ${this.address}`);
87
+
88
+ const feeRate = this.feeRateSatVb || (await this.provider.fetchFeeRateSatVb());
89
+ const feeSat = Math.ceil(ANCHOR_TX_VBYTES * feeRate);
90
+ const utxo = utxos[0];
91
+
92
+ const payload = encodeOpReturnPayload(merkleRoot);
93
+ const { rawHex, txid } = createOpReturnTx({
94
+ keyPair: this.keyPair,
95
+ fromAddress: this.address,
96
+ utxo,
97
+ dataBuffer: payload,
98
+ feeSat,
99
+ network: this.network,
100
+ });
101
+ const broadcastTxid = await this.provider.broadcastTx(rawHex);
102
+
103
+ const proofs = {};
104
+ hashes.forEach((h, i) => {
105
+ if (!(h in proofs)) proofs[h] = tree.getProof(i);
106
+ });
107
+
108
+ const batch = {
109
+ batchId: merkleRoot,
110
+ merkleRoot,
111
+ txid: broadcastTxid || txid,
112
+ createdAt: new Date().toISOString(),
113
+ hashes,
114
+ proofs,
115
+ status: "broadcast",
116
+ blockHeight: null,
117
+ blockHash: null,
118
+ blockTime: null,
119
+ };
120
+
121
+ await this.store.saveBatch(batch);
122
+ await this.store.clearPending(pending.map((p) => p.id));
123
+ return batch;
124
+ }
125
+
126
+ /**
127
+ * Start automatic settlement. A batch is anchored as soon as the OLDEST
128
+ * pending hash has waited `intervalMs` (default: one day). Nothing is sent
129
+ * while the queue is empty. Because the wait is measured from the stored
130
+ * `addedAt` time, a server restart does not reset it.
131
+ */
132
+ start({ intervalMs } = {}) {
133
+ if (this._timer) return;
134
+ if (intervalMs) this.intervalMs = intervalMs;
135
+ const checkMs = Math.min(this.intervalMs, 60_000);
136
+ this._timer = setInterval(() => {
137
+ this.settleIfDue().catch((err) => console.error("[btcnotar] scheduled settle() failed:", err));
138
+ }, checkMs);
139
+ this._timer.unref?.();
140
+ }
141
+
142
+ /**
143
+ * Anchor one hash right now (plus anything else already queued) and
144
+ * return its proof. If the hash is already anchored, returns the existing
145
+ * proof without a new transaction (`alreadyAnchored: true`).
146
+ */
147
+ async anchorNow(hashHex) {
148
+ if (!isHex(hashHex)) throw new Error(`hash must be a hex string, got "${hashHex}"`);
149
+ const hash = hashHex.toLowerCase();
150
+ const existing = await this.getProof(hash);
151
+ if (existing) return { ...existing, alreadyAnchored: true };
152
+ await this.addHash(hash);
153
+ await this.settle();
154
+ const proof = await this.getProof(hash);
155
+ if (!proof) throw new Error("anchoring failed: hash not found in any batch");
156
+ return { ...proof, alreadyAnchored: false };
157
+ }
158
+
159
+ /** Settle only if the oldest pending hash has waited at least `intervalMs`. Returns the batch or null. */
160
+ async settleIfDue(now = Date.now()) {
161
+ if (this._settling) return null;
162
+ this._settling = true; // set before any await, so two calls can never both pass
163
+ try {
164
+ const pending = await this.store.listPending();
165
+ if (!pending.length) return null;
166
+ const oldest = Math.min(...pending.map((p) => Date.parse(p.addedAt) || now));
167
+ if (now - oldest < this.intervalMs) return null;
168
+ return await this.settle();
169
+ } finally {
170
+ this._settling = false;
171
+ }
172
+ }
173
+
174
+ stop() {
175
+ if (this._timer) {
176
+ clearInterval(this._timer);
177
+ this._timer = null;
178
+ }
179
+ }
180
+
181
+ /** Look up the stored proof + anchor info for a given document hash. */
182
+ async getProof(hashHex) {
183
+ const hash = hashHex.toLowerCase();
184
+ const batch = await this.store.findBatchByHash(hash);
185
+ if (!batch) return null;
186
+ return {
187
+ hash,
188
+ merkleRoot: batch.merkleRoot,
189
+ proof: batch.proofs[hash],
190
+ txid: batch.txid,
191
+ batchId: batch.batchId,
192
+ createdAt: batch.createdAt,
193
+ blockHeight: batch.blockHeight,
194
+ blockHash: batch.blockHash,
195
+ blockTime: batch.blockTime,
196
+ };
197
+ }
198
+
199
+ /** When the queue will be anchored next (oldest pending addedAt + intervalMs), or null if empty. */
200
+ async nextSettleAt() {
201
+ const pending = await this.store.listPending();
202
+ if (!pending.length) return null;
203
+ const oldest = Math.min(...pending.map((p) => Date.parse(p.addedAt) || Date.now()));
204
+ return new Date(oldest + this.intervalMs).toISOString();
205
+ }
206
+
207
+ /**
208
+ * Where is this hash? { status: "anchored", ...proof } | { status: "pending", addedAt, nextSettleAt } | null
209
+ */
210
+ async getStatus(hashHex) {
211
+ const hash = String(hashHex).toLowerCase();
212
+ const proof = await this.getProof(hash);
213
+ if (proof) return { status: "anchored", ...proof };
214
+ const queued = (await this.store.listPending()).find((p) => p.hash === hash);
215
+ if (queued) return { status: "pending", hash, addedAt: queued.addedAt, nextSettleAt: await this.nextSettleAt() };
216
+ return null;
217
+ }
218
+
219
+ /** Re-fetch a batch's anchoring transaction and update its confirmation status. */
220
+ async refreshStatus(batchId) {
221
+ const batch = await this.store.getBatch(batchId);
222
+ if (!batch) return null;
223
+ const tx = await this.provider.fetchTransaction(batch.txid);
224
+ if (tx) {
225
+ batch.status = tx.confirmed ? "confirmed" : "pending";
226
+ batch.blockHeight = tx.blockHeight;
227
+ batch.blockHash = tx.blockHash;
228
+ batch.blockTime = tx.blockTime;
229
+ await this.store.saveBatch(batch);
230
+ }
231
+ return batch;
232
+ }
233
+
234
+ /**
235
+ * The reversibility check: given a document hash, prove — or disprove —
236
+ * that it is anchored on the Bitcoin blockchain.
237
+ *
238
+ * This (1) recomputes the Merkle root from the hash + its stored proof
239
+ * and (2) fetches the anchoring transaction live from the chain and
240
+ * confirms its OP_RETURN output actually carries that same root. Only
241
+ * when both checks agree is the result reported as valid, together with
242
+ * the transaction id, block hash/height and date.
243
+ */
244
+ async verify(hashHex, proofRecord) {
245
+ const record = proofRecord || (await this.getProof(hashHex));
246
+ return verifyProof(hashHex, record, this.provider);
247
+ }
248
+ }
package/src/store.js ADDED
@@ -0,0 +1,109 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * In-memory store: hashes waiting for the next settlement batch, and
6
+ * settled batches keyed by batchId (= their Merkle root), indexed by the
7
+ * individual document hashes they contain. This is also the base class
8
+ * for FileStore below.
9
+ */
10
+ export class MemoryStore {
11
+ constructor() {
12
+ this.pending = [];
13
+ this.batches = new Map();
14
+ this.hashIndex = new Map();
15
+ }
16
+
17
+ async addPending(entry) {
18
+ this.pending.push(entry);
19
+ return entry;
20
+ }
21
+
22
+ async listPending() {
23
+ return [...this.pending];
24
+ }
25
+
26
+ async clearPending(ids) {
27
+ const remove = new Set(ids);
28
+ this.pending = this.pending.filter((e) => !remove.has(e.id));
29
+ }
30
+
31
+ async saveBatch(batch) {
32
+ this.batches.set(batch.batchId, batch);
33
+ for (const h of batch.hashes) this.hashIndex.set(h, batch.batchId);
34
+ return batch;
35
+ }
36
+
37
+ async getBatch(batchId) {
38
+ return this.batches.get(batchId) || null;
39
+ }
40
+
41
+ async findBatchByHash(hashHex) {
42
+ const batchId = this.hashIndex.get(hashHex.toLowerCase());
43
+ return batchId ? this.batches.get(batchId) || null : null;
44
+ }
45
+
46
+ async listBatches() {
47
+ return [...this.batches.values()];
48
+ }
49
+ }
50
+
51
+ /**
52
+ * JSON-file-backed store: same interface as MemoryStore, persisted to disk
53
+ * after every write so pending hashes and settled batches survive process
54
+ * restarts. Fine for a single-process daemon; swap in a real database for
55
+ * anything with concurrent writers.
56
+ */
57
+ export class FileStore extends MemoryStore {
58
+ constructor({ filePath }) {
59
+ super();
60
+ if (!filePath) throw new Error("FileStore requires a filePath");
61
+ this.filePath = filePath;
62
+ this._load();
63
+ }
64
+
65
+ _load() {
66
+ try {
67
+ const raw = fs.readFileSync(this.filePath, "utf8");
68
+ const data = JSON.parse(raw);
69
+ this.pending = data.pending || [];
70
+ this.batches = new Map(Object.entries(data.batches || {}));
71
+ this._reindex();
72
+ } catch (e) {
73
+ if (e.code !== "ENOENT") throw e;
74
+ }
75
+ }
76
+
77
+ _reindex() {
78
+ this.hashIndex = new Map();
79
+ for (const batch of this.batches.values()) {
80
+ for (const h of batch.hashes) this.hashIndex.set(h, batch.batchId);
81
+ }
82
+ }
83
+
84
+ _persist() {
85
+ const data = {
86
+ pending: this.pending,
87
+ batches: Object.fromEntries(this.batches),
88
+ };
89
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
90
+ fs.writeFileSync(this.filePath, JSON.stringify(data, null, 2));
91
+ }
92
+
93
+ async addPending(entry) {
94
+ await super.addPending(entry);
95
+ this._persist();
96
+ return entry;
97
+ }
98
+
99
+ async clearPending(ids) {
100
+ await super.clearPending(ids);
101
+ this._persist();
102
+ }
103
+
104
+ async saveBatch(batch) {
105
+ await super.saveBatch(batch);
106
+ this._persist();
107
+ return batch;
108
+ }
109
+ }
package/src/verify.js ADDED
@@ -0,0 +1,40 @@
1
+ import { MerkleTree } from "./merkle.js";
2
+ import { extractOpReturnData } from "./bitcoin.js";
3
+
4
+ /**
5
+ * Verify a proof record against the live blockchain, trusting nobody:
6
+ * (1) recompute the Merkle root from the hash + proof,
7
+ * (2) fetch the transaction from the chain and check its OP_RETURN carries that root.
8
+ *
9
+ * record: { merkleRoot, proof, txid } (as returned by getProof / a notary server)
10
+ * provider: anything with fetchTransaction(txid), e.g. MempoolProvider
11
+ */
12
+ export async function verifyProof(hashHex, record, provider) {
13
+ const hash = String(hashHex).toLowerCase();
14
+ if (!record || !record.merkleRoot || !record.txid || !Array.isArray(record.proof)) {
15
+ return { valid: false, reason: "no-proof-found" };
16
+ }
17
+
18
+ if (!MerkleTree.verifyProof(hash, record.proof, record.merkleRoot)) {
19
+ return { valid: false, reason: "merkle-proof-mismatch" };
20
+ }
21
+
22
+ const tx = await provider.fetchTransaction(record.txid);
23
+ if (!tx) return { valid: false, reason: "transaction-not-found", txid: record.txid };
24
+
25
+ const onChainRoot = extractOpReturnData(tx);
26
+ if (!onChainRoot || onChainRoot !== record.merkleRoot) {
27
+ return { valid: false, reason: "on-chain-data-mismatch", txid: record.txid };
28
+ }
29
+
30
+ return {
31
+ valid: true,
32
+ confirmed: !!tx.confirmed,
33
+ txid: record.txid,
34
+ merkleRoot: record.merkleRoot,
35
+ blockHeight: tx.blockHeight ?? null,
36
+ blockHash: tx.blockHash ?? null,
37
+ blockTime: tx.blockTime ?? null,
38
+ date: tx.blockTime ? new Date(tx.blockTime * 1000).toISOString() : null,
39
+ };
40
+ }