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 +21 -0
- package/README.md +207 -3
- package/bin/cli.js +116 -0
- package/index.js +15 -0
- package/package.json +49 -6
- package/src/bitcoin.js +133 -0
- package/src/client.js +68 -0
- package/src/merkle.js +122 -0
- package/src/notary.js +248 -0
- package/src/store.js +109 -0
- package/src/verify.js +40 -0
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
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|