btcnotar 0.1.0 → 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/README.md CHANGED
@@ -74,6 +74,37 @@ Only when both agree is the hash reported as anchored — together with the
74
74
  transaction id, block hash/height and date, so "is this document on the
75
75
  blockchain, and when/where" has one unambiguous answer.
76
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
+
77
108
  ## API
78
109
 
79
110
  ### `new BTCNotar(options)`
@@ -97,6 +128,7 @@ blockchain, and when/where" has one unambiguous answer.
97
128
  nothing was pending.
98
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`).
99
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`.
100
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.
101
133
  - `getProof(hashHex)` — look up the stored proof + anchor info for a hash.
102
134
  - `verify(hashHex)` — cryptographically and on-chain verification (see above).
package/bin/cli.js CHANGED
@@ -1,12 +1,16 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  import "dotenv/config";
3
- import { BTCNotar } from "../index.js";
3
+ import { BTCNotar, BTCNotarClient, sha256Hex } from "../index.js";
4
4
  import { FileStore } from "../src/store.js";
5
5
 
6
6
  function usage() {
7
7
  console.log(`btcnotar - batch Bitcoin notarization CLI
8
8
 
9
- Usage:
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):
10
14
  btcnotar add <hash> Queue a document hash for the next settlement
11
15
  btcnotar settle Settle all pending hashes now (build Merkle tree + anchor on-chain)
12
16
  btcnotar proof <hash> Print the stored proof for a hash
@@ -14,6 +18,7 @@ Usage:
14
18
  btcnotar address Print the notary's Bitcoin address
15
19
 
16
20
  Environment variables:
21
+ BTCNOTAR_SERVER Server for submit/check (or --server <url>). Default: the free public server
17
22
  BTCNOTAR_WIF Bitcoin private key (WIF) used to fund/sign anchor transactions [required]
18
23
  BTCNOTAR_NETWORK "mainnet" or "testnet" (default: mainnet)
19
24
  BTCNOTAR_STORE Path to the JSON store file (default: ./.btcnotar/store.json)
@@ -31,11 +36,37 @@ function buildNotary() {
31
36
  return new BTCNotar({ wif, network, store: new FileStore({ filePath }) });
32
37
  }
33
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
+
34
51
  async function main() {
35
- const [cmd, arg] = process.argv.slice(2);
52
+ const args = process.argv.slice(2);
53
+ const cmd = args[0];
36
54
 
37
55
  if (!cmd || cmd === "-h" || cmd === "--help") return usage();
38
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
+
39
70
  if (cmd === "address") {
40
71
  const notary = buildNotary();
41
72
  console.log(notary.address);
package/index.js CHANGED
@@ -1,4 +1,6 @@
1
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";
2
4
  export { MerkleTree } from "./src/merkle.js";
3
5
  export { MemoryStore, FileStore } from "./src/store.js";
4
6
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "btcnotar",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "type": "module",
6
6
  "main": "index.js",
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/notary.js CHANGED
@@ -3,7 +3,8 @@ import * as bitcoin from "bitcoinjs-lib";
3
3
  import * as ecc from "tiny-secp256k1";
4
4
  import { ECPairFactory } from "ecpair";
5
5
  import { MerkleTree } from "./merkle.js";
6
- import { MempoolProvider, createOpReturnTx, encodeOpReturnPayload, extractOpReturnData } from "./bitcoin.js";
6
+ import { MempoolProvider, createOpReturnTx, encodeOpReturnPayload } from "./bitcoin.js";
7
+ import { verifyProof } from "./verify.js";
7
8
  import { MemoryStore } from "./store.js";
8
9
 
9
10
  bitcoin.initEccLib(ecc);
@@ -195,6 +196,26 @@ export class BTCNotar {
195
196
  };
196
197
  }
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
+
198
219
  /** Re-fetch a batch's anchoring transaction and update its confirmation status. */
199
220
  async refreshStatus(batchId) {
200
221
  const batch = await this.store.getBatch(batchId);
@@ -221,31 +242,7 @@ export class BTCNotar {
221
242
  * the transaction id, block hash/height and date.
222
243
  */
223
244
  async verify(hashHex, proofRecord) {
224
- const hash = hashHex.toLowerCase();
225
- const record = proofRecord || (await this.getProof(hash));
226
- if (!record) return { valid: false, reason: "no-proof-found" };
227
-
228
- if (!MerkleTree.verifyProof(hash, record.proof, record.merkleRoot)) {
229
- return { valid: false, reason: "merkle-proof-mismatch" };
230
- }
231
-
232
- const tx = await this.provider.fetchTransaction(record.txid);
233
- if (!tx) return { valid: false, reason: "transaction-not-found", txid: record.txid };
234
-
235
- const onChainRoot = extractOpReturnData(tx);
236
- if (!onChainRoot || onChainRoot !== record.merkleRoot) {
237
- return { valid: false, reason: "on-chain-data-mismatch", txid: record.txid };
238
- }
239
-
240
- return {
241
- valid: true,
242
- confirmed: !!tx.confirmed,
243
- txid: record.txid,
244
- merkleRoot: record.merkleRoot,
245
- blockHeight: tx.blockHeight ?? null,
246
- blockHash: tx.blockHash ?? null,
247
- blockTime: tx.blockTime ?? null,
248
- date: tx.blockTime ? new Date(tx.blockTime * 1000).toISOString() : null,
249
- };
245
+ const record = proofRecord || (await this.getProof(hashHex));
246
+ return verifyProof(hashHex, record, this.provider);
250
247
  }
251
248
  }
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
+ }