@dogfood-lab/ingest 1.3.2 → 1.5.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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Merkle tree over chain entry digests — the leaf computation the XRPL anchor
3
+ * commits to on-chain.
4
+ *
5
+ * Ported from @mcptoolshop/repomesh's anchor/xrpl/scripts/merkle.mjs (the
6
+ * proven, tested design). The RFC-6962 (Certificate Transparency) v2 algorithm
7
+ * is the default: it is second-preimage-safe (closes CVE-2012-2459) via domain
8
+ * separation —
9
+ * leaf hash = sha256(0x00 || leafBytes)
10
+ * internal node = sha256(0x01 || left || right)
11
+ * lone odd node = CARRIED UP UNCHANGED (no duplicate-last)
12
+ *
13
+ * Pure module: no I/O, no deps beyond node:crypto. The leaves are the chain
14
+ * entries' `submission_digest` values (64-char hex sha256) for the partition
15
+ * being anchored — see compute-root.js.
16
+ */
17
+
18
+ import { createHash } from 'node:crypto';
19
+
20
+ function sha256(buf) {
21
+ return createHash('sha256').update(buf).digest();
22
+ }
23
+
24
+ // Domain-separation prefixes for the RFC-6962 (Certificate Transparency) tree.
25
+ const LEAF_PREFIX = Buffer.from([0x00]);
26
+ const NODE_PREFIX = Buffer.from([0x01]);
27
+
28
+ function validateLeaves(leavesHex) {
29
+ if (!Array.isArray(leavesHex) || leavesHex.length === 0) {
30
+ throw new Error('merkle: need at least 1 leaf');
31
+ }
32
+ return leavesHex.map((h, i) => {
33
+ if (typeof h !== 'string' || !/^[0-9a-fA-F]{64}$/.test(h)) {
34
+ throw new Error(`Invalid leaf[${i}] (expected 64 hex chars): ${h}`);
35
+ }
36
+ return Buffer.from(h, 'hex');
37
+ });
38
+ }
39
+
40
+ /**
41
+ * v1 — historical algorithm. NO domain separation; the lone odd node is
42
+ * DUPLICATED (the CVE-2012-2459 dup-last shape). Ported byte-identical so that
43
+ * any partition anchored under v1 in a prior system still verifies. New
44
+ * partitions should use v2. Do NOT change this function.
45
+ *
46
+ * @param {string[]} leavesHex - 64-char hex leaves (one per chain entry digest).
47
+ * @returns {string} Root as lowercase hex.
48
+ */
49
+ export function merkleRootHex(leavesHex) {
50
+ let level = validateLeaves(leavesHex);
51
+ while (level.length > 1) {
52
+ const next = [];
53
+ for (let i = 0; i < level.length; i += 2) {
54
+ const left = level[i];
55
+ const right = (i + 1 < level.length) ? level[i + 1] : level[i];
56
+ next.push(sha256(Buffer.concat([left, right])));
57
+ }
58
+ level = next;
59
+ }
60
+ return level[0].toString('hex');
61
+ }
62
+
63
+ /**
64
+ * v2 — RFC-6962 (Certificate Transparency). Domain separation closes the
65
+ * leaf/node ambiguity AND the dup-last second-preimage (CVE-2012-2459):
66
+ * leaf hash = sha256(0x00 || leafBytes)
67
+ * internal node = sha256(0x01 || left || right)
68
+ * lone odd node = carried up unchanged
69
+ *
70
+ * @param {string[]} leavesHex - 64-char hex leaves.
71
+ * @returns {string} Root as lowercase hex.
72
+ */
73
+ export function merkleRootHexV2(leavesHex) {
74
+ const raw = validateLeaves(leavesHex);
75
+ // First hash every leaf with the leaf-domain prefix.
76
+ let level = raw.map((b) => sha256(Buffer.concat([LEAF_PREFIX, b])));
77
+ while (level.length > 1) {
78
+ const next = [];
79
+ for (let i = 0; i < level.length; i += 2) {
80
+ if (i + 1 < level.length) {
81
+ next.push(sha256(Buffer.concat([NODE_PREFIX, level[i], level[i + 1]])));
82
+ } else {
83
+ next.push(level[i]); // carry the lone odd node up unchanged
84
+ }
85
+ }
86
+ level = next;
87
+ }
88
+ return level[0].toString('hex');
89
+ }
90
+
91
+ /**
92
+ * Compute a root for the requested algo. Defaults to RFC-6962 v2 — the
93
+ * second-preimage-safe variant is what new anchors commit to. Pass
94
+ * `sha256-merkle-v1` only to reproduce a historical v1 partition.
95
+ *
96
+ * @param {string[]} leavesHex
97
+ * @param {string} [algo='sha256-merkle-v2']
98
+ * @returns {string}
99
+ */
100
+ export function merkleRootForAlgo(leavesHex, algo = 'sha256-merkle-v2') {
101
+ if (algo === 'sha256-merkle-v2') return merkleRootHexV2(leavesHex);
102
+ if (algo === 'sha256-merkle-v1') return merkleRootHex(leavesHex);
103
+ throw new Error(`Unknown merkle algo: ${algo}`);
104
+ }
105
+
106
+ /**
107
+ * Build a self-describing manifest fragment: the algo, leaf encoding, count,
108
+ * and root. The leaf encoding label documents that leaves are the chain
109
+ * entries' submission_digest hex values.
110
+ *
111
+ * @param {string[]} leavesHex
112
+ * @param {string} [algo='sha256-merkle-v2']
113
+ * @returns {{ algo: string, leafEncoding: string, leafCount: number, root: string }}
114
+ */
115
+ export function merkleManifest(leavesHex, algo = 'sha256-merkle-v2') {
116
+ const root = merkleRootForAlgo(leavesHex, algo);
117
+ return {
118
+ algo,
119
+ leafEncoding: 'submissionDigest:hex(32)',
120
+ leafCount: leavesHex.length,
121
+ root,
122
+ };
123
+ }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * post-anchor — submit an anchor manifest's Merkle root to the XRP Ledger as a
3
+ * memo on an AccountSet transaction.
4
+ *
5
+ * Adapted from repomesh's post-anchor.mjs. This is the ONLY module in the anchor
6
+ * subsystem that touches the network, and it LAZILY imports `xrpl` so the rest
7
+ * of the subsystem (compute-root, verify-anchor, the truncation check, the memo
8
+ * builder, and ALL tests) runs with xrpl NOT installed. xrpl is intentionally
9
+ * NOT a declared dependency anywhere; only `postAnchor()` reaches for it, and it
10
+ * throws a structured install hint when it is absent.
11
+ *
12
+ * ── Network (default TESTNET) ───────────────────────────────────────────────
13
+ * Default endpoint is the XRPL testnet (wss://s.altnet.rippletest.net:51233).
14
+ * Testnet ledgers are periodically RESET, which purges historical transactions —
15
+ * fine for proving the mechanism, but for DURABLE, citable anchors migrate to
16
+ * mainnet by passing `network: 'mainnet'` (or XRPL_WS_URL) and funding a real
17
+ * wallet. Each AccountSet anchor costs the standard XRP tx fee.
18
+ *
19
+ * ── Authorization root ──────────────────────────────────────────────────────
20
+ * Any funded wallet can post a memo; the trust comes from the verifier's
21
+ * trusted-anchor-account allowlist (see verify-anchor.js). Posting requires the
22
+ * XRPL_SEED env var (the funded wallet's family seed); post-anchor REFUSES to
23
+ * submit without it. The on-chain memo self-describes its Merkle algo so the
24
+ * standalone verifier recomputes the root with the same algorithm.
25
+ *
26
+ * Honest framing: a confirmed on-chain XRPL transaction is the EXTERNAL
27
+ * head/count witness the chain writer cannot forge or remove. That is what
28
+ * upgrades the chain from tamper-EVIDENT to tamper-PROOF below an anchored point.
29
+ */
30
+
31
+ import { readFileSync } from 'node:fs';
32
+
33
+ import { readLastAnchor, computeAnchor } from './compute-root.js';
34
+
35
+ /** Default XRPL endpoints per network. */
36
+ export const DEFAULT_WS_URL = {
37
+ testnet: 'wss://s.altnet.rippletest.net:51233',
38
+ mainnet: 'wss://xrplcluster.com',
39
+ };
40
+
41
+ /** Ripple epoch offset from the Unix epoch (seconds): 2000-01-01T00:00:00Z. */
42
+ const RIPPLE_EPOCH_OFFSET_SECONDS = 946684800;
43
+
44
+ /** Memo type that self-identifies an anchor memo so a verifier can locate it. */
45
+ export const ANCHOR_MEMO_TYPE = 'testing-os-anchor-v1';
46
+
47
+ function stringToHex(s) {
48
+ return Buffer.from(s, 'utf8').toString('hex').toUpperCase();
49
+ }
50
+
51
+ /**
52
+ * Validate the XRPL_SEED SHAPE before opening a socket, so a typo'd/empty seed
53
+ * gives a one-line actionable message instead of a raw crypto stack after the
54
+ * connect. XRPL family seeds are base58 (no 0/O/I/l), begin with 's', and are
55
+ * ~29-31 chars. This is a SHAPE pre-check, not a full base58-check — the xrpl
56
+ * Wallet.fromSeed remains the authority. Pure / testable.
57
+ *
58
+ * @param {string} seed
59
+ * @returns {{ ok: boolean, reason?: string }}
60
+ */
61
+ export function validateSeedShape(seed) {
62
+ if (typeof seed !== 'string' || seed.length === 0) {
63
+ return { ok: false, reason: "XRPL_SEED is empty or unset — set the funded wallet's seed (generate with: xrpl wallet create)." };
64
+ }
65
+ if (seed[0] !== 's') {
66
+ return { ok: false, reason: `XRPL_SEED does not look like an XRPL family seed — it must begin with 's' (got "${seed[0]}…"). Did you paste the classic ADDRESS (r…) instead of the SEED?` };
67
+ }
68
+ if (seed.length < 16 || seed.length > 40) {
69
+ return { ok: false, reason: `XRPL_SEED has an unexpected length (${seed.length}); an XRPL seed is ~29-31 base58 chars. Check for truncation or extra whitespace.` };
70
+ }
71
+ if (/[0OIl]/.test(seed) || !/^[1-9A-HJ-NP-Za-km-z]+$/.test(seed)) {
72
+ return { ok: false, reason: 'XRPL_SEED contains characters outside the base58 alphabet (no 0, O, I, l) — it is not a valid seed.' };
73
+ }
74
+ return { ok: true };
75
+ }
76
+
77
+ /**
78
+ * Convert an XRPL Ripple-epoch close-time (seconds since 2000-01-01) into an
79
+ * ISO-8601 string. `submitAndWait` returns the validated tx, whose
80
+ * `result.date` is the ledger close-time — the only trustworthy clock for an
81
+ * anchor. Returns null (never throws) when the date is absent.
82
+ *
83
+ * @param {object} submitResult
84
+ * @returns {string|null}
85
+ */
86
+ export function extractCloseTime(submitResult) {
87
+ const date = submitResult?.result?.date;
88
+ if (typeof date !== 'number' || !Number.isFinite(date)) return null;
89
+ const d = new Date((date + RIPPLE_EPOCH_OFFSET_SECONDS) * 1000);
90
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
91
+ }
92
+
93
+ /**
94
+ * Extract the validating ledger index from a submitAndWait result. Returns null
95
+ * (never throws) when absent.
96
+ *
97
+ * @param {object} submitResult
98
+ * @returns {number|null}
99
+ */
100
+ export function extractLedgerIndex(submitResult) {
101
+ const li = submitResult?.result?.ledger_index;
102
+ return typeof li === 'number' && Number.isFinite(li) ? li : null;
103
+ }
104
+
105
+ /**
106
+ * Build the XRPL memo object binding an anchor to the chain. Pure — returns the
107
+ * `{ Memo: { MemoType, MemoFormat, MemoData } }` envelope (hex-encoded fields).
108
+ *
109
+ * The memo self-describes the Merkle algo so the standalone verifier recomputes
110
+ * the root with the SAME algorithm that produced it. MemoType is a stable
111
+ * self-identifying tag (ANCHOR_MEMO_TYPE) so a verifier can locate the memo.
112
+ *
113
+ * @param {object} params
114
+ * @param {number} params.anchorSeq
115
+ * @param {string} params.network - 'testnet' | 'mainnet'
116
+ * @param {string} params.rootHex - The Merkle root.
117
+ * @param {string} params.manifestHash - sha256 of the canonical manifest base.
118
+ * @param {number} params.count - leaf_count.
119
+ * @param {string|null} [params.prev] - prev_anchor_root, or null/"0" for genesis.
120
+ * @param {[number,number]|null} [params.range] - seq_range [from, to].
121
+ * @param {string} [params.algo] - Merkle algo (omitted from memo when undefined).
122
+ * @returns {{ Memo: { MemoType: string, MemoFormat: string, MemoData: string } }}
123
+ */
124
+ export function buildAnchorMemo({ anchorSeq, network, rootHex, manifestHash, count, prev, range, algo }) {
125
+ const dataObj = {
126
+ v: 1,
127
+ s: anchorSeq,
128
+ n: network,
129
+ r: rootHex,
130
+ h: manifestHash,
131
+ c: count,
132
+ pv: prev && prev !== null ? prev : '0',
133
+ rg: range ? `${range[0]}..${range[1]}` : '0',
134
+ };
135
+ // Self-describe the algorithm so v2 anchors verify as v2 (and any legacy v1
136
+ // memo without an algo field falls back to v1 in the verifier).
137
+ if (algo) dataObj.algo = algo;
138
+ const memoData = JSON.stringify(dataObj);
139
+ if (Buffer.byteLength(memoData, 'utf8') > 700) {
140
+ throw new Error(`MemoData too large: ${Buffer.byteLength(memoData)} bytes (XRPL memo cap is ~1KB; keep anchors small)`);
141
+ }
142
+ return {
143
+ Memo: {
144
+ MemoType: stringToHex(ANCHOR_MEMO_TYPE),
145
+ MemoFormat: stringToHex('application/json'),
146
+ MemoData: stringToHex(memoData),
147
+ },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Structured error for the missing optional dependency. Thrown by postAnchor()
153
+ * (the only network path) when `import('xrpl')` fails — so the floor + verify +
154
+ * all tests run without xrpl, and only an actual post demands it.
155
+ */
156
+ export class XrplDependencyError extends Error {
157
+ constructor() {
158
+ super('XRPL anchoring requires the optional xrpl package — run: npm install xrpl');
159
+ this.name = 'XrplDependencyError';
160
+ this.code = 'XRPL_NOT_INSTALLED';
161
+ this.hint = 'npm install xrpl';
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Lazily import the optional `xrpl` package. Isolated so it can be stubbed in
167
+ * tests and so the structured install hint is produced in exactly one place.
168
+ *
169
+ * @returns {Promise<object>} The xrpl module.
170
+ * @throws {XrplDependencyError} when xrpl is not installed.
171
+ */
172
+ export async function loadXrpl() {
173
+ try {
174
+ return await import('xrpl');
175
+ } catch {
176
+ throw new XrplDependencyError();
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Compute (if needed) and post the next anchor to the XRP Ledger.
182
+ *
183
+ * Steps:
184
+ * 1. Compute the anchor manifest if one is not already pending — uses the same
185
+ * append-only compute-root path. (Operators usually run anchor-compute
186
+ * first; this re-runs it idempotently so a post is never against a stale or
187
+ * missing manifest.)
188
+ * 2. Validate XRPL_SEED shape — REFUSE to post without it (structured error).
189
+ * 3. Lazily import xrpl, connect, submitAndWait an AccountSet carrying the
190
+ * anchor memo.
191
+ * 4. On tesSUCCESS, record the on-chain close-time + ledger index back into the
192
+ * anchor manifest (via the provided manifest-update callback) and return the
193
+ * receipt.
194
+ *
195
+ * The actual xrpl client + wallet are injected via `deps` so tests can drive the
196
+ * whole post path WITHOUT xrpl installed and WITHOUT a network. In production
197
+ * `deps` is omitted and the real xrpl is lazily loaded.
198
+ *
199
+ * @param {string} repoRoot
200
+ * @param {object} [opts]
201
+ * @param {'testnet'|'mainnet'} [opts.network='testnet']
202
+ * @param {string} [opts.wsUrl] - Override endpoint (else DEFAULT_WS_URL[network]).
203
+ * @param {string} [opts.seed] - Override XRPL_SEED (else process.env.XRPL_SEED).
204
+ * @param {'since-last'|'all'} [opts.mode='since-last']
205
+ * @param {object} [opts.deps] - Test injection: { xrpl, recordPost }.
206
+ * @returns {Promise<object>} A receipt: { ok, anchor_seq, root, tx_hash, ... }.
207
+ */
208
+ export async function postAnchor(repoRoot, opts = {}) {
209
+ const network = opts.network || 'testnet';
210
+ const wsUrl = opts.wsUrl || DEFAULT_WS_URL[network] || DEFAULT_WS_URL.testnet;
211
+ const seed = opts.seed !== undefined ? opts.seed : process.env.XRPL_SEED;
212
+ const mode = opts.mode || 'since-last';
213
+
214
+ // (2) Refuse without a valid-shaped seed — BEFORE any network work.
215
+ const seedCheck = validateSeedShape(seed);
216
+ if (!seedCheck.ok) {
217
+ const e = new Error(seedCheck.reason);
218
+ e.code = 'XRPL_SEED_INVALID';
219
+ throw e;
220
+ }
221
+
222
+ // (1) Ensure there is a manifest to post. Re-running compute is idempotent
223
+ // (append-only). If nothing new is pending, post the LAST anchor manifest
224
+ // so a re-post after a transient network failure still binds the same root.
225
+ const computed = computeAnchor(repoRoot, { mode, network });
226
+ let manifest;
227
+ if (computed.empty) {
228
+ manifest = readLastAnchor(repoRoot);
229
+ if (!manifest) {
230
+ const e = new Error(`nothing to anchor: ${computed.reason}`);
231
+ e.code = 'ANCHOR_NOTHING_TO_POST';
232
+ throw e;
233
+ }
234
+ } else {
235
+ manifest = computed.manifest;
236
+ }
237
+
238
+ const memo = buildAnchorMemo({
239
+ anchorSeq: manifest.anchor_seq,
240
+ network,
241
+ rootHex: manifest.root,
242
+ manifestHash: manifest.manifest_hash,
243
+ count: manifest.leaf_count,
244
+ prev: manifest.prev_anchor_root,
245
+ range: manifest.seq_range,
246
+ algo: manifest.algo,
247
+ });
248
+
249
+ // (3) Lazily load xrpl (or use the injected stub) and submit.
250
+ const xrpl = opts.deps?.xrpl || (await loadXrpl());
251
+ const client = new xrpl.Client(wsUrl);
252
+ await client.connect();
253
+ let receipt;
254
+ try {
255
+ const wallet = xrpl.Wallet.fromSeed(seed);
256
+ const tx = {
257
+ TransactionType: 'AccountSet',
258
+ Account: wallet.address,
259
+ Memos: [memo],
260
+ };
261
+ const result = await client.submitAndWait(tx, { wallet });
262
+ const txHash = result?.result?.hash || result?.result?.tx_json?.hash || null;
263
+ const engineResult = result?.result?.meta?.TransactionResult || result?.result?.engine_result;
264
+ const closeTimeIso = extractCloseTime(result);
265
+ const ledgerIndex = extractLedgerIndex(result);
266
+
267
+ receipt = {
268
+ ok: engineResult === 'tesSUCCESS',
269
+ network,
270
+ anchor_seq: manifest.anchor_seq,
271
+ root: manifest.root,
272
+ manifest_hash: manifest.manifest_hash,
273
+ leaf_count: manifest.leaf_count,
274
+ seq_range: manifest.seq_range,
275
+ head_digest: manifest.head_digest,
276
+ tx_hash: txHash,
277
+ wallet_address: wallet.address,
278
+ ledger_index: ledgerIndex,
279
+ close_time_iso: closeTimeIso,
280
+ engine_result: engineResult,
281
+ };
282
+ } finally {
283
+ await client.disconnect();
284
+ }
285
+
286
+ // (4) Record the on-chain facts back into the anchor manifest. Injected in
287
+ // tests; in production it patches the manifest file in place.
288
+ if (receipt.ok && typeof opts.deps?.recordPost === 'function') {
289
+ opts.deps.recordPost(manifest.anchor_seq, {
290
+ tx_hash: receipt.tx_hash,
291
+ ledger_index: receipt.ledger_index,
292
+ close_time_iso: receipt.close_time_iso,
293
+ wallet_address: receipt.wallet_address,
294
+ network,
295
+ });
296
+ }
297
+
298
+ return receipt;
299
+ }