Skip to content

Commit 4ac8867

Browse files
forge: extract bech32 + Blocktrails trail math to lib/blocktrails.js
First slice of splitting the ~4900-line plugin.js into modules. The bech32/ bech32m encoders (npub + P2TR address) and the chained BIP-341 TapTweak trail math are pure functions over node:crypto + @noble/curves with no forge state, so they lift out cleanly. plugin.js re-exports markStateHash/npubEncode/ trailAddress/trailProgram so test imports are unchanged. Behavior identical — 127 tests still green. No redeploy (running instances keep their version).
1 parent 0aea3c2 commit 4ac8867

2 files changed

Lines changed: 135 additions & 120 deletions

File tree

forge/lib/blocktrails.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// forge/lib/blocktrails.js — pure crypto extracted from plugin.js (no forge
2+
// state): bech32 / bech32m encoders (npub + P2TR address) and the Blocktrails
3+
// chained BIP-341 TapTweak trail math. Pure functions over node:crypto +
4+
// @noble/curves. plugin.js re-exports markStateHash / npubEncode / trailAddress
5+
// / trailProgram from here so the test imports stay unchanged.
6+
import crypto from 'node:crypto';
7+
import { secp256k1 } from '@noble/curves/secp256k1';
8+
9+
// bech32 (BIP-173, full checksum — no shortcuts) npub encoder, pure node.
10+
// Unit-tested against the canonical NIP-19 vector in test.js. Tier 3.5
11+
// generalizes the same polymod into a bech32m (BIP-350) P2TR address
12+
// encoder — the two specs differ ONLY by the checksum constant
13+
// (bech32 xors 1, bech32m xors 0x2bc830a3).
14+
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
15+
const BECH32M_CONST = 0x2bc830a3;
16+
17+
function bech32Polymod(values) {
18+
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
19+
let chk = 1;
20+
for (const v of values) {
21+
const top = chk >>> 25;
22+
chk = ((chk & 0x1ffffff) << 5) ^ v;
23+
for (let i = 0; i < 5; i++) if ((top >>> i) & 1) chk ^= GEN[i];
24+
}
25+
return chk;
26+
}
27+
28+
/** 8-bit bytes regrouped big-endian into 5-bit words (padded). */
29+
function to5bit(bytes) {
30+
const words = [];
31+
let acc = 0;
32+
let bits = 0;
33+
for (const b of bytes) {
34+
acc = (acc << 8) | b;
35+
bits += 8;
36+
while (bits >= 5) { bits -= 5; words.push((acc >>> bits) & 31); }
37+
}
38+
if (bits) words.push((acc << (5 - bits)) & 31);
39+
return words;
40+
}
41+
42+
/** hrp + data words + 6-word checksum (constant picks bech32 vs bech32m). */
43+
function bech32Assemble(hrp, words, constant) {
44+
const expanded = [...hrp].map((c) => c.charCodeAt(0) >>> 5)
45+
.concat([0], [...hrp].map((c) => c.charCodeAt(0) & 31));
46+
const poly = bech32Polymod([...expanded, ...words, 0, 0, 0, 0, 0, 0]) ^ constant;
47+
const checksum = Array.from({ length: 6 }, (_, i) => (poly >>> (5 * (5 - i))) & 31);
48+
return `${hrp}1${[...words, ...checksum].map((d) => BECH32_CHARSET[d]).join('')}`;
49+
}
50+
51+
/** 64-hex pubkey -> npub1... (NIP-19). Exported for the vector test. */
52+
export function npubEncode(hex) {
53+
return bech32Assemble('npub', to5bit(Buffer.from(hex, 'hex')), 1);
54+
}
55+
56+
/** 32-byte P2TR witness program -> bech32m address (BIP-350, version 1). */
57+
export function p2trAddressEncode(hrp, program) {
58+
return bech32Assemble(hrp, [1, ...to5bit(program)], BECH32M_CONST);
59+
}
60+
61+
// ------------------------------------------- Blocktrails trail math (3.5)
62+
// Implemented FROM THE SPEC (blocktrails spec v0.2 — the mirror at
63+
// blocktrails/spec/spec.md is normative): chained BIP-341 TapTweak.
64+
//
65+
// h = sha256(serialize(state)) state hash
66+
// tᵢ = tagged_hash("TapTweak", x_only(Pᵢ₋₁) || h) mod n
67+
// Pᵢ = Pᵢ₋₁ + tᵢ·G full point kept
68+
// output = x_only(Pᵢ) → bech32m (tb1p…) x-only at the boundary
69+
//
70+
// serialize(state) is the git-mark-demo convention:
71+
// JSON.stringify({ commit, repo, branch }) — canonical because the object
72+
// is built literally in that key order everywhere it is hashed.
73+
// Parity note (a Finding): the spec keeps FULL points through the chain
74+
// and takes x_only only at the output boundary — x(P) == x(-P) makes the
75+
// output comparison parity-free, and the tweak input is the raw
76+
// x-coordinate of the (possibly odd-Y) running point, NOT a re-lifted
77+
// even-Y key as in BIP-341 wallet derivation. Cross-checked in test.js
78+
// against a vector generated by the maintainer's reference implementation.
79+
// The blocktrails npm package is deliberately not imported (spec cited,
80+
// math local); @noble/curves does the point arithmetic.
81+
82+
const SECP_N = secp256k1.CURVE.n;
83+
const sha256Of = (...parts) => {
84+
const h = crypto.createHash('sha256');
85+
for (const p of parts) h.update(p);
86+
return h.digest();
87+
};
88+
const TAPTWEAK_TAG = sha256Of(Buffer.from('TapTweak', 'utf8'));
89+
90+
/** Canonical mark state hash: sha256 hex of the state JSON. Exported for tests. */
91+
export function markStateHash(state) {
92+
return crypto.createHash('sha256').update(JSON.stringify(state), 'utf8').digest('hex');
93+
}
94+
95+
/** BIP-341 TapTweak scalar over (x_only(P), stateHash), in [1, n-1]. */
96+
function tapTweakScalar(xOnly, stateHashBytes) {
97+
const t = BigInt(`0x${sha256Of(TAPTWEAK_TAG, TAPTWEAK_TAG, xOnly, stateHashBytes).toString('hex')}`) % SECP_N;
98+
// The spec REQUIRES rejecting t = 0 (probability ~2^-256).
99+
if (t === 0n) throw new Error('blocktrails: TapTweak scalar is zero — state rejected');
100+
return t;
101+
}
102+
103+
/** Chain the tweaks: base pubkey + one TapTweak per state hash (hex list). */
104+
function trailPoint(pubkeyBaseHex, stateHashes) {
105+
let P = secp256k1.ProjectivePoint.fromHex(pubkeyBaseHex);
106+
for (const h of stateHashes) {
107+
const xOnly = P.toRawBytes(true).subarray(1); // raw x of the running point
108+
const t = tapTweakScalar(xOnly, Buffer.from(h, 'hex'));
109+
P = P.add(secp256k1.ProjectivePoint.BASE.multiply(t));
110+
}
111+
return P;
112+
}
113+
114+
/** Witness program (x-only, 64-hex) after chaining stateHashes. Exported for tests. */
115+
export function trailProgram(pubkeyBaseHex, stateHashes) {
116+
return Buffer.from(trailPoint(pubkeyBaseHex, stateHashes).toRawBytes(true).subarray(1)).toString('hex');
117+
}
118+
119+
/** Derived P2TR address after chaining stateHashes. Exported for tests. */
120+
export function trailAddress(pubkeyBaseHex, stateHashes, hrp) {
121+
return p2trAddressEncode(hrp, Buffer.from(trailProgram(pubkeyBaseHex, stateHashes), 'hex'));
122+
}
123+
124+
/** Shortened npub for UI rendering: npub1abcd…wxyz. */
125+
export function npubShort(hex) {
126+
const npub = npubEncode(hex);
127+
return `${npub.slice(0, 9)}${npub.slice(-4)}`;
128+
}

forge/plugin.js

Lines changed: 7 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ import { fileURLToPath } from 'node:url';
132132
import { promisify } from 'node:util';
133133

134134
import { schnorr, secp256k1 } from '@noble/curves/secp256k1';
135+
import {
136+
npubEncode, p2trAddressEncode, markStateHash, trailProgram, trailAddress, npubShort,
137+
} from './lib/blocktrails.js';
138+
// re-export the four the test suite imports from plugin.js (back-compat)
139+
export { markStateHash, npubEncode, trailAddress, trailProgram } from './lib/blocktrails.js';
135140

136141
const execFileP = promisify(execFile);
137142

@@ -324,126 +329,8 @@ function nostrHexOf(agent) {
324329
return m ? m[1] : null;
325330
}
326331

327-
// bech32 (BIP-173, full checksum — no shortcuts) npub encoder, pure node.
328-
// Unit-tested against the canonical NIP-19 vector in test.js. Tier 3.5
329-
// generalizes the same polymod into a bech32m (BIP-350) P2TR address
330-
// encoder — the two specs differ ONLY by the checksum constant
331-
// (bech32 xors 1, bech32m xors 0x2bc830a3).
332-
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
333-
const BECH32M_CONST = 0x2bc830a3;
334-
335-
function bech32Polymod(values) {
336-
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
337-
let chk = 1;
338-
for (const v of values) {
339-
const top = chk >>> 25;
340-
chk = ((chk & 0x1ffffff) << 5) ^ v;
341-
for (let i = 0; i < 5; i++) if ((top >>> i) & 1) chk ^= GEN[i];
342-
}
343-
return chk;
344-
}
345-
346-
/** 8-bit bytes regrouped big-endian into 5-bit words (padded). */
347-
function to5bit(bytes) {
348-
const words = [];
349-
let acc = 0;
350-
let bits = 0;
351-
for (const b of bytes) {
352-
acc = (acc << 8) | b;
353-
bits += 8;
354-
while (bits >= 5) { bits -= 5; words.push((acc >>> bits) & 31); }
355-
}
356-
if (bits) words.push((acc << (5 - bits)) & 31);
357-
return words;
358-
}
359-
360-
/** hrp + data words + 6-word checksum (constant picks bech32 vs bech32m). */
361-
function bech32Assemble(hrp, words, constant) {
362-
const expanded = [...hrp].map((c) => c.charCodeAt(0) >>> 5)
363-
.concat([0], [...hrp].map((c) => c.charCodeAt(0) & 31));
364-
const poly = bech32Polymod([...expanded, ...words, 0, 0, 0, 0, 0, 0]) ^ constant;
365-
const checksum = Array.from({ length: 6 }, (_, i) => (poly >>> (5 * (5 - i))) & 31);
366-
return `${hrp}1${[...words, ...checksum].map((d) => BECH32_CHARSET[d]).join('')}`;
367-
}
368-
369-
/** 64-hex pubkey -> npub1... (NIP-19). Exported for the vector test. */
370-
export function npubEncode(hex) {
371-
return bech32Assemble('npub', to5bit(Buffer.from(hex, 'hex')), 1);
372-
}
373-
374-
/** 32-byte P2TR witness program -> bech32m address (BIP-350, version 1). */
375-
export function p2trAddressEncode(hrp, program) {
376-
return bech32Assemble(hrp, [1, ...to5bit(program)], BECH32M_CONST);
377-
}
378-
379-
// ------------------------------------------- Blocktrails trail math (3.5)
380-
// Implemented FROM THE SPEC (blocktrails spec v0.2 — the mirror at
381-
// blocktrails/spec/spec.md is normative): chained BIP-341 TapTweak.
382-
//
383-
// h = sha256(serialize(state)) state hash
384-
// tᵢ = tagged_hash("TapTweak", x_only(Pᵢ₋₁) || h) mod n
385-
// Pᵢ = Pᵢ₋₁ + tᵢ·G full point kept
386-
// output = x_only(Pᵢ) → bech32m (tb1p…) x-only at the boundary
387-
//
388-
// serialize(state) is the git-mark-demo convention:
389-
// JSON.stringify({ commit, repo, branch }) — canonical because the object
390-
// is built literally in that key order everywhere it is hashed.
391-
// Parity note (a Finding): the spec keeps FULL points through the chain
392-
// and takes x_only only at the output boundary — x(P) == x(-P) makes the
393-
// output comparison parity-free, and the tweak input is the raw
394-
// x-coordinate of the (possibly odd-Y) running point, NOT a re-lifted
395-
// even-Y key as in BIP-341 wallet derivation. Cross-checked in test.js
396-
// against a vector generated by the maintainer's reference implementation.
397-
// The blocktrails npm package is deliberately not imported (spec cited,
398-
// math local); @noble/curves does the point arithmetic.
399-
400-
const SECP_N = secp256k1.CURVE.n;
401-
const sha256Of = (...parts) => {
402-
const h = crypto.createHash('sha256');
403-
for (const p of parts) h.update(p);
404-
return h.digest();
405-
};
406-
const TAPTWEAK_TAG = sha256Of(Buffer.from('TapTweak', 'utf8'));
407-
408-
/** Canonical mark state hash: sha256 hex of the state JSON. Exported for tests. */
409-
export function markStateHash(state) {
410-
return crypto.createHash('sha256').update(JSON.stringify(state), 'utf8').digest('hex');
411-
}
412-
413-
/** BIP-341 TapTweak scalar over (x_only(P), stateHash), in [1, n-1]. */
414-
function tapTweakScalar(xOnly, stateHashBytes) {
415-
const t = BigInt(`0x${sha256Of(TAPTWEAK_TAG, TAPTWEAK_TAG, xOnly, stateHashBytes).toString('hex')}`) % SECP_N;
416-
// The spec REQUIRES rejecting t = 0 (probability ~2^-256).
417-
if (t === 0n) throw new Error('blocktrails: TapTweak scalar is zero — state rejected');
418-
return t;
419-
}
420-
421-
/** Chain the tweaks: base pubkey + one TapTweak per state hash (hex list). */
422-
function trailPoint(pubkeyBaseHex, stateHashes) {
423-
let P = secp256k1.ProjectivePoint.fromHex(pubkeyBaseHex);
424-
for (const h of stateHashes) {
425-
const xOnly = P.toRawBytes(true).subarray(1); // raw x of the running point
426-
const t = tapTweakScalar(xOnly, Buffer.from(h, 'hex'));
427-
P = P.add(secp256k1.ProjectivePoint.BASE.multiply(t));
428-
}
429-
return P;
430-
}
431-
432-
/** Witness program (x-only, 64-hex) after chaining stateHashes. Exported for tests. */
433-
export function trailProgram(pubkeyBaseHex, stateHashes) {
434-
return Buffer.from(trailPoint(pubkeyBaseHex, stateHashes).toRawBytes(true).subarray(1)).toString('hex');
435-
}
436-
437-
/** Derived P2TR address after chaining stateHashes. Exported for tests. */
438-
export function trailAddress(pubkeyBaseHex, stateHashes, hrp) {
439-
return p2trAddressEncode(hrp, Buffer.from(trailProgram(pubkeyBaseHex, stateHashes), 'hex'));
440-
}
441-
442-
/** Shortened npub for UI rendering: npub1abcd…wxyz. */
443-
function npubShort(hex) {
444-
const npub = npubEncode(hex);
445-
return `${npub.slice(0, 9)}${npub.slice(-4)}`;
446-
}
332+
// bech32 (npub) + bech32m (P2TR) + Blocktrails TapTweak trail math moved to
333+
// ./lib/blocktrails.js (pure, no forge state) — imported & re-exported above.
447334

448335
/** Agent -> pod username OR 64-hex nostr namespace (2.5), or null. */
449336
function ownerFromAgent(agent) {

0 commit comments

Comments
 (0)