btcnotar 0.1.0 → 0.2.1
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 +193 -127
- package/bin/cli.js +35 -4
- package/index.js +2 -0
- package/package.json +7 -4
- package/src/client.js +68 -0
- package/src/notary.js +24 -27
- package/src/verify.js +40 -0
package/README.md
CHANGED
|
@@ -1,174 +1,240 @@
|
|
|
1
1
|
# btcnotar
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Prove that a text or a file existed at a certain time — using the Bitcoin blockchain.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
`
|
|
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.
|
|
5
|
+
- **Free.** No Bitcoin, no wallet, no key. The public server pays the fee.
|
|
6
|
+
- **Private.** Only the SHA-256 hash is sent. Your file never leaves your computer.
|
|
7
|
+
- **Trustless.** `verify()` checks the proof directly on the blockchain, not on our server.
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
```bash
|
|
10
|
+
npm install btcnotar
|
|
11
|
+
```
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
thousands of hashes into one Merkle root per settlement means:
|
|
13
|
+
---
|
|
17
14
|
|
|
18
|
-
|
|
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.
|
|
15
|
+
## Easy examples
|
|
23
16
|
|
|
24
|
-
|
|
17
|
+
All examples use the free public server. Save them as `.mjs` files and run with `node`.
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
### 1. Notarize a text
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
// notarize-text.mjs
|
|
23
|
+
import { BTCNotarClient, sha256Hex } from "btcnotar";
|
|
24
|
+
|
|
25
|
+
const client = new BTCNotarClient();
|
|
26
|
+
const hash = sha256Hex("Hello, Bitcoin!");
|
|
27
|
+
|
|
28
|
+
const result = await client.submit(hash);
|
|
29
|
+
console.log(result);
|
|
30
|
+
// { status: "pending", hash: "...", nextSettleAt: "2026-10-25T15:00:00.000Z" }
|
|
28
31
|
```
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
`pending` means: your hash is in the queue. It goes on the blockchain at `nextSettleAt` (about 1 hour).
|
|
34
|
+
|
|
35
|
+
### 2. Check it later
|
|
31
36
|
|
|
32
37
|
```js
|
|
33
|
-
|
|
38
|
+
// check-text.mjs
|
|
39
|
+
import { BTCNotarClient, sha256Hex } from "btcnotar";
|
|
40
|
+
|
|
41
|
+
const client = new BTCNotarClient();
|
|
42
|
+
const result = await client.verify(sha256Hex("Hello, Bitcoin!"));
|
|
43
|
+
|
|
44
|
+
if (result.valid) {
|
|
45
|
+
console.log("On the blockchain!");
|
|
46
|
+
console.log("Date: ", result.date);
|
|
47
|
+
console.log("Block:", result.blockHeight);
|
|
48
|
+
console.log("Tx: ", `https://mempool.space/tx/${result.txid}`);
|
|
49
|
+
} else {
|
|
50
|
+
console.log("Not yet:", result.reason); // "pending" = still waiting
|
|
51
|
+
}
|
|
52
|
+
```
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
54
|
+
### 3. Notarize a file
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
// notarize-file.mjs
|
|
58
|
+
import fs from "node:fs";
|
|
59
|
+
import { BTCNotarClient, sha256Hex } from "btcnotar";
|
|
41
60
|
|
|
42
|
-
|
|
43
|
-
|
|
61
|
+
const client = new BTCNotarClient();
|
|
62
|
+
const hash = sha256Hex(fs.readFileSync("contract.pdf"));
|
|
44
63
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
64
|
+
console.log("SHA-256:", hash);
|
|
65
|
+
console.log(await client.submit(hash));
|
|
66
|
+
```
|
|
48
67
|
|
|
49
|
-
|
|
68
|
+
Keep the file **exactly as it is**. If one byte changes, the hash changes and the proof will not match.
|
|
50
69
|
|
|
51
|
-
|
|
52
|
-
// { hash, merkleRoot, proof, txid, batchId, blockHeight, blockHash, blockTime, ... }
|
|
70
|
+
### 4. Many texts, save results, check later
|
|
53
71
|
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
```js
|
|
73
|
+
// many.mjs
|
|
74
|
+
import fs from "node:fs";
|
|
75
|
+
import { BTCNotarClient, sha256Hex } from "btcnotar";
|
|
76
|
+
|
|
77
|
+
const client = new BTCNotarClient();
|
|
78
|
+
const texts = ["first text", "second text", "third text"];
|
|
79
|
+
|
|
80
|
+
const results = [];
|
|
81
|
+
for (const text of texts) {
|
|
82
|
+
const hash = sha256Hex(text);
|
|
83
|
+
const r = await client.submit(hash);
|
|
84
|
+
results.push({ text, hash, status: r.status, nextSettleAt: r.nextSettleAt });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fs.writeFileSync("results.json", JSON.stringify(results, null, 2));
|
|
88
|
+
console.log("Saved to results.json");
|
|
64
89
|
```
|
|
65
90
|
|
|
66
|
-
|
|
91
|
+
```js
|
|
92
|
+
// check-many.mjs
|
|
93
|
+
import fs from "node:fs";
|
|
94
|
+
import { BTCNotarClient } from "btcnotar";
|
|
67
95
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
2. **On-chain**: fetches the anchoring transaction live from the blockchain
|
|
71
|
-
and confirms its `OP_RETURN` output actually carries that same root.
|
|
96
|
+
const client = new BTCNotarClient();
|
|
97
|
+
const results = JSON.parse(fs.readFileSync("results.json", "utf8"));
|
|
72
98
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
for (const item of results) {
|
|
100
|
+
const r = await client.verify(item.hash);
|
|
101
|
+
console.log(item.text, "->", r.valid ? `YES, block ${r.blockHeight}, ${r.date}` : `not yet (${r.reason})`);
|
|
102
|
+
}
|
|
103
|
+
```
|
|
76
104
|
|
|
77
|
-
|
|
105
|
+
All texts sent in the same hour go into **one** Bitcoin transaction.
|
|
78
106
|
|
|
79
|
-
###
|
|
80
|
-
|
|
81
|
-
| option | required | default | description |
|
|
82
|
-
|---------------|----------|---------------------|-------------|
|
|
83
|
-
| `wif` | yes | — | Bitcoin private key (WIF) used to fund and sign anchor transactions |
|
|
84
|
-
| `network` | no | `"mainnet"` | `"mainnet"` or `"testnet"` |
|
|
85
|
-
| `store` | no | `new MemoryStore()` | pending hashes + settled batches storage (see below) |
|
|
86
|
-
| `provider` | no | `new MempoolProvider()` | chain data source (UTXOs, fees, broadcast, tx lookup) |
|
|
87
|
-
| `feeRateSatVb`| no | fetched live | fixed fee rate override (sat/vB) |
|
|
88
|
-
| `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) |
|
|
89
|
-
|
|
90
|
-
### Instance methods
|
|
91
|
-
|
|
92
|
-
- `addHash(hashHex)` — queue a hex-encoded hash for the next settlement.
|
|
93
|
-
- `pendingCount()` — number of hashes waiting to be settled.
|
|
94
|
-
- `settle()` — build a Merkle tree over everything pending and anchor its
|
|
95
|
-
root in one `OP_RETURN` transaction right now. Returns the batch record
|
|
96
|
-
(`{ batchId, merkleRoot, txid, hashes, proofs, ... }`), or `null` if
|
|
97
|
-
nothing was pending.
|
|
98
|
-
- `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
|
-
- `settleIfDue()` — the check `start()` runs; anchors only if something has waited `intervalMs`.
|
|
100
|
-
- `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
|
-
- `getProof(hashHex)` — look up the stored proof + anchor info for a hash.
|
|
102
|
-
- `verify(hashHex)` — cryptographically and on-chain verification (see above).
|
|
103
|
-
- `refreshStatus(batchId)` — re-fetch a batch's transaction and update its
|
|
104
|
-
confirmation / block info.
|
|
105
|
-
|
|
106
|
-
### `MerkleTree`
|
|
107
|
-
|
|
108
|
-
The underlying Merkle tree, usable standalone:
|
|
107
|
+
### 5. Command line (no code)
|
|
109
108
|
|
|
110
|
-
```
|
|
111
|
-
|
|
109
|
+
```bash
|
|
110
|
+
npx btcnotar submit "Hello, Bitcoin!"
|
|
111
|
+
npx btcnotar check "Hello, Bitcoin!"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
You can give a text or a SHA-256 hash (64 hex characters).
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## How long does it take?
|
|
119
|
+
|
|
120
|
+
| Step | Time |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `submit()` | instant — status `pending` |
|
|
123
|
+
| Hash goes into a Bitcoin transaction | at `nextSettleAt` (about every hour) |
|
|
124
|
+
| Transaction is in a block | about 10 minutes later |
|
|
125
|
+
|
|
126
|
+
After the transaction is sent, `verify()` returns `valid: true`.
|
|
127
|
+
`confirmed: false` means it is still waiting for a block. `confirmed: true` means it is in a block.
|
|
112
128
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
129
|
+
## What `verify()` returns
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
{ valid: true, confirmed: true, txid: "...", blockHeight: 920123, date: "2026-10-25T15:08:12.000Z", merkleRoot: "..." }
|
|
133
|
+
{ valid: false, reason: "pending", nextSettleAt: "..." } // waiting in the queue
|
|
134
|
+
{ valid: false, reason: "unknown-hash" } // never sent
|
|
135
|
+
{ valid: false, reason: "merkle-proof-mismatch" } // proof is wrong
|
|
136
|
+
{ valid: false, reason: "on-chain-data-mismatch" } // blockchain has different data
|
|
117
137
|
```
|
|
118
138
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
## Why you don't need to trust the server
|
|
140
|
+
|
|
141
|
+
`verify()` downloads the proof from the server, then:
|
|
142
|
+
|
|
143
|
+
1. rebuilds the Merkle root from your hash and the proof, and
|
|
144
|
+
2. reads the real Bitcoin transaction from [mempool.space](https://mempool.space) and checks that its `OP_RETURN` contains that root.
|
|
145
|
+
|
|
146
|
+
If the server lies, the check fails.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Run your own notary (with your own Bitcoin)
|
|
123
151
|
|
|
124
|
-
|
|
152
|
+
If you want your own server, use `BTCNotar` with your own key (WIF) and a funded address.
|
|
125
153
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
`clearPending`, `saveBatch`, `getBatch`, `findBatchByHash`, `listBatches`.
|
|
129
|
-
Implement the same interface against a real database for multi-process or
|
|
130
|
-
production use.
|
|
154
|
+
```js
|
|
155
|
+
import { BTCNotar, FileStore, sha256Hex } from "btcnotar";
|
|
131
156
|
|
|
132
|
-
|
|
157
|
+
const notary = new BTCNotar({
|
|
158
|
+
wif: process.env.BTCNOTAR_WIF, // your private key (WIF)
|
|
159
|
+
store: new FileStore({ filePath: "./store.json" }), // keeps queue and proofs
|
|
160
|
+
intervalMs: 60 * 60 * 1000, // anchor every hour (if queue is not empty)
|
|
161
|
+
});
|
|
133
162
|
|
|
134
|
-
|
|
135
|
-
REST API for UTXOs, fee estimation, broadcasting and transaction lookup.
|
|
136
|
-
Supply your own object implementing `fetchUtxos`, `fetchFeeRateSatVb`,
|
|
137
|
-
`broadcastTx`, `fetchTransaction` to use a different backend (e.g. a local
|
|
138
|
-
`bitcoind` or Electrum server).
|
|
163
|
+
console.log("Fund this address:", notary.address);
|
|
139
164
|
|
|
140
|
-
|
|
165
|
+
await notary.addHash(sha256Hex("my document")); // add to queue
|
|
166
|
+
notary.start(); // anchors automatically
|
|
141
167
|
|
|
142
|
-
|
|
168
|
+
// or right now:
|
|
169
|
+
const proof = await notary.anchorNow(sha256Hex("urgent document"));
|
|
170
|
+
console.log(proof.txid);
|
|
171
|
+
|
|
172
|
+
// check:
|
|
173
|
+
console.log(await notary.verify(sha256Hex("my document")));
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Command line for your own notary:
|
|
143
177
|
|
|
144
178
|
```bash
|
|
145
|
-
export BTCNOTAR_WIF=...
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
npx btcnotar
|
|
150
|
-
npx btcnotar settle
|
|
151
|
-
npx btcnotar proof <hash>
|
|
152
|
-
npx btcnotar verify <hash>
|
|
153
|
-
npx btcnotar address
|
|
179
|
+
export BTCNOTAR_WIF=... # required
|
|
180
|
+
npx btcnotar address # address to fund
|
|
181
|
+
npx btcnotar add <hash> # add to queue
|
|
182
|
+
npx btcnotar settle # send the transaction now
|
|
183
|
+
npx btcnotar verify <hash> # check on the blockchain
|
|
154
184
|
```
|
|
155
185
|
|
|
156
|
-
|
|
157
|
-
external scheduler (system cron, a serverless cron trigger) instead of the
|
|
158
|
-
built-in `start()` interval.
|
|
186
|
+
---
|
|
159
187
|
|
|
160
|
-
##
|
|
188
|
+
## API
|
|
161
189
|
|
|
162
|
-
|
|
163
|
-
|
|
190
|
+
### `new BTCNotarClient(options?)` — free public server
|
|
191
|
+
|
|
192
|
+
| option | default | description |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| `serverUrl` | public server | your own server URL |
|
|
195
|
+
| `network` | `"mainnet"` | `"mainnet"` or `"testnet"` |
|
|
196
|
+
| `provider` | `MempoolProvider` | where `verify()` reads the blockchain |
|
|
197
|
+
|
|
198
|
+
- `submit(hash)` — add a hash to the queue → `{ status, hash, addedAt, nextSettleAt }`
|
|
199
|
+
- `verify(hash)` — check on the blockchain → see [What verify() returns](#what-verify-returns)
|
|
200
|
+
- `getProof(hash)` — raw proof, `{ status: "pending" }`, or `null`
|
|
201
|
+
|
|
202
|
+
### `sha256Hex(textOrBuffer)`
|
|
203
|
+
|
|
204
|
+
SHA-256 of a string (UTF-8) or a Buffer, as a hex string.
|
|
205
|
+
|
|
206
|
+
### `new BTCNotar(options)` — your own notary
|
|
207
|
+
|
|
208
|
+
| option | default | description |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `wif` | — (required) | private key that pays and signs |
|
|
211
|
+
| `network` | `"mainnet"` | `"mainnet"` or `"testnet"` |
|
|
212
|
+
| `store` | `MemoryStore` (lost on exit) | use `FileStore({ filePath })` to keep data |
|
|
213
|
+
| `intervalMs` | 24 hours | `start()` anchors when the oldest queued hash is this old |
|
|
214
|
+
| `feeRateSatVb` | live from mempool.space | fixed fee (sat/vB) |
|
|
215
|
+
| `provider` | `MempoolProvider` | blockchain backend |
|
|
216
|
+
|
|
217
|
+
Methods: `address`, `addHash(hash)`, `pendingCount()`, `settle()`, `anchorNow(hash)`, `start()`, `stop()`, `settleIfDue()`, `getStatus(hash)`, `getProof(hash)`, `verify(hash)`, `refreshStatus(batchId)`.
|
|
218
|
+
|
|
219
|
+
### Other exports
|
|
220
|
+
|
|
221
|
+
`MerkleTree`, `verifyProof(hash, proof, provider)`, `MemoryStore`, `FileStore`, `MempoolProvider`, `DEFAULT_SERVER_URL`.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## How it looks on the blockchain
|
|
226
|
+
|
|
227
|
+
Every batch is one transaction with an `OP_RETURN` output:
|
|
164
228
|
|
|
165
229
|
```
|
|
166
|
-
|
|
230
|
+
42544e31 + <Merkle root, 32 bytes>
|
|
231
|
+
"BTN1"
|
|
167
232
|
```
|
|
168
233
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
234
|
+
Your hash is not visible on the blockchain — only the Merkle root of all hashes in the batch.
|
|
235
|
+
The proof connects your hash to that root.
|
|
236
|
+
|
|
237
|
+
Merkle tree: leaf = `sha256(0x00 || hash)`, node = `sha256(0x01 || left || right)`, an odd node is moved up unchanged.
|
|
172
238
|
|
|
173
239
|
## License
|
|
174
240
|
|
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
|
-
|
|
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
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "btcnotar",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Free Bitcoin notarization: prove a text or file existed at a certain time. Send a SHA-256 hash, get a proof you can verify yourself on the Bitcoin blockchain (Merkle tree + OP_RETURN).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"timestamping",
|
|
31
31
|
"op_return",
|
|
32
32
|
"proof-of-existence",
|
|
33
|
-
"blockchain"
|
|
33
|
+
"blockchain",
|
|
34
|
+
"timestamp",
|
|
35
|
+
"sha256"
|
|
34
36
|
],
|
|
35
37
|
"author": "Yordan Karparov",
|
|
36
38
|
"license": "MIT",
|
|
@@ -45,5 +47,6 @@
|
|
|
45
47
|
"dotenv": "^17.2.3",
|
|
46
48
|
"ecpair": "^3.0.0",
|
|
47
49
|
"tiny-secp256k1": "^2.2.4"
|
|
48
|
-
}
|
|
50
|
+
},
|
|
51
|
+
"homepage": "https://github.com/yordank/BTCNotar#readme"
|
|
49
52
|
}
|
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
|
|
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
|
|
225
|
-
|
|
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
|
+
}
|