@powforge/identity
Advanced tools
+45
-0
| # Changelog | ||
| ## 0.7.0-beta.2 | ||
| ### Added | ||
| - `getChaintip({ source })` now accepts an optional source config. Pass | ||
| `{ type: 'bitcoind-rpc', url, user, password }` to fetch the tip from a | ||
| self-hosted Bitcoin Core node via JSON-RPC (`getblockcount` + | ||
| `getbestblockhash`) instead of mempool.space. The returned envelope is | ||
| unchanged (`{ height, hash, observed_at }`), so downstream signature | ||
| verification is wire-compatible. | ||
| - 60s in-memory cache is now keyed per source, so callers using both | ||
| mempool.space and a local RPC in the same process do not poison each | ||
| other's cached values. | ||
| ### Backwards compatibility | ||
| - Default source remains mempool.space. Consumers who | ||
| `npm install @powforge/identity` without configuring anything see | ||
| byte-identical behavior to 0.7.0-beta.1. The RPC mode is strictly | ||
| opt-in via `opts.source`. | ||
| ### Consumers migrated in this repo | ||
| - `scripts/identity-score-server.js` loads creds from | ||
| `~/.config/powforge/bitcoin-rpc` at boot and threads the RPC source | ||
| through every `?chaintip=1` request. If the primary source fails, it | ||
| degrades to mempool.space before setting `bitcoin_tip_error`. | ||
| - `scripts/lib/bitcoin-pulse.js` auto-detects the creds file and prefers | ||
| local RPC for tip/fees/mempool. Fees come from `estimatesmartfee` at | ||
| N=1/3/6/144/504 blocks (fastest / halfHour / hour / economy / minimum), | ||
| converted BTC/kvB -> sat/vB with ceiling rounding. Graceful fallback | ||
| to mempool.space on RPC error with a single stderr warning per process. | ||
| - New helper: `scripts/lib/bitcoin-rpc-config.js` parses the creds file | ||
| once and returns either a source object ready for `getChaintip`/pulse | ||
| or null for the fallback path. | ||
| ### Motivation | ||
| - Fubz directive msg 1510 (2026-04-23): "wire @powforge/identity chaintip | ||
| + forge-tick bitcoin_pulse to use the internal Bitcoin node at | ||
| lightning.lan:8332." Local RPC means lower latency, no mempool.space | ||
| rate limit, and a stronger trust story ("oracle queries the operator's | ||
| own node, not a public API"). | ||
| ### Beta tag | ||
| - Published as `@beta` on npm so `latest` stays pinned to 0.6.0 for | ||
| existing callers. Consumers opt in via | ||
| `npm install @powforge/identity@beta`. | ||
| ## 0.7.0-beta.1 | ||
@@ -4,0 +49,0 @@ |
+1
-1
| { | ||
| "name": "@powforge/identity", | ||
| "version": "0.7.0-beta.1", | ||
| "version": "0.7.0-beta.2", | ||
| "description": "Depth-of-Identity SDK for Nostr. Measures accumulated irreversible work across five dimensions. Try it live at powforge.dev/explorer.", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+164
-31
@@ -10,19 +10,21 @@ /** | ||
| * What this provides: | ||
| * - getChaintip() returns { height, hash, observed_at } from mempool.space | ||
| * - getChaintip() returns { height, hash, observed_at } | ||
| * Default source: mempool.space HTTP endpoints | ||
| * Optional source: a user-provided bitcoind JSON-RPC | ||
| * endpoint (see `opts.source`). | ||
| * - signWithChaintip() thin wrapper that adds bitcoin_tip under the signer's payload | ||
| * - 60-second in-memory cache to avoid hammering mempool.space on hot paths | ||
| * - 60-second in-memory cache to avoid hammering any single source on hot paths | ||
| * | ||
| * What this deliberately does NOT provide: | ||
| * - SPV verification, Merkle proofs, or header-chain validation | ||
| * - A self-hosted Bitcoin RPC fallback (see TODO below) | ||
| * - Automatic discovery of a bitcoind endpoint — callers thread `opts.source` in | ||
| * | ||
| * Backwards-compat contract: | ||
| * A consumer who does `npm install @powforge/identity` and calls | ||
| * `getChaintip()` with no args sees the same mempool.space path as before. | ||
| * The RPC mode is strictly opt-in via `opts.source`. | ||
| * | ||
| * Honest claim for downstream copy: | ||
| * "Block height + hash as a freshness anchor, verifiable against any | ||
| * public Bitcoin node." | ||
| * | ||
| * Upgrade path: | ||
| * TODO(bitcoind-rpc): When Fubz credentials arrive, swap the mempool.space | ||
| * HTTP fetch for a direct bitcoind JSON-RPC call (getblockchaininfo). The | ||
| * public API becomes `CHAINTIP_RPC_URL` env var plus auth, and the cache | ||
| * window can shrink because RPC latency drops an order of magnitude. | ||
| */ | ||
@@ -36,29 +38,96 @@ | ||
| let cached = null; // { value: {height, hash, observed_at}, expires_at: ms-epoch } | ||
| // Cache is keyed by the resolved source identity so that two callers with | ||
| // different sources (e.g. one using mempool.space, one using a local RPC) do | ||
| // not poison each other's cached value. Keyspace stays tiny in practice — a | ||
| // Node process realistically has one or two source identities. | ||
| const cacheByKey = new Map(); // key -> { value, expires_at } | ||
| /** | ||
| * Fetch current Bitcoin tip height and hash from mempool.space. | ||
| * Derive a stable cache key for a given source config. Keeps the cache per | ||
| * source so a caller who switches from mempool.space to an RPC between calls | ||
| * doesn't briefly get a stale value from the other. | ||
| */ | ||
| function sourceCacheKey(source) { | ||
| if (!source || source.type === 'mempool-space') return 'mempool-space'; | ||
| if (source.type === 'bitcoind-rpc') { | ||
| return `bitcoind-rpc:${source.url || ''}`; | ||
| } | ||
| return `unknown:${source.type || 'none'}`; | ||
| } | ||
| /** | ||
| * Encode HTTP Basic auth for a user/password pair. Safe to call with either | ||
| * the explicit basic string (preferred: caller can pre-encode once) or the | ||
| * raw credentials. | ||
| */ | ||
| function basicAuthHeader(source) { | ||
| if (source.authBasic) return `Basic ${source.authBasic}`; | ||
| const user = source.user || ''; | ||
| const password = source.password || ''; | ||
| const b64 = Buffer.from(`${user}:${password}`).toString('base64'); | ||
| return `Basic ${b64}`; | ||
| } | ||
| /** | ||
| * Fetch tip from a bitcoind JSON-RPC endpoint. Issues two RPC calls | ||
| * (`getblockcount` + `getbestblockhash`) and returns the same | ||
| * `{ height, hash, observed_at }` shape as the mempool.space path. | ||
| * | ||
| * Returns { height, hash, observed_at } where observed_at is an ISO-8601 | ||
| * timestamp of when the fetch landed on this host (not a block timestamp). | ||
| * Cached for 60 seconds in-process. On fetch error, throws so callers can | ||
| * decide whether to ship the score without the anchor. | ||
| * | ||
| * @param {object} [opts] | ||
| * @param {Function} [opts.fetchImpl] Injected fetch for tests. Defaults to globalThis.fetch. | ||
| * @param {number} [opts.timeoutMs] Per-request timeout in ms. Default 3000. | ||
| * @returns {Promise<{height: number, hash: string, observed_at: string}>} | ||
| * Throws on network error, HTTP non-2xx, JSON-RPC-level error, or malformed | ||
| * fields. Callers are expected to catch and decide whether to degrade to the | ||
| * default source (`bitcoin-pulse.js` and the identity-score server both do | ||
| * this). | ||
| */ | ||
| async function getChaintip(opts = {}) { | ||
| const now = Date.now(); | ||
| if (cached && cached.expires_at > now) { | ||
| return cached.value; | ||
| async function fetchTipFromRpc(source, fetchImpl, timeoutMs) { | ||
| if (!source.url) throw new Error('chaintip: source.url required for bitcoind-rpc'); | ||
| async function rpc(method, params = []) { | ||
| const controller = new AbortController(); | ||
| const t = setTimeout(() => controller.abort(), timeoutMs); | ||
| try { | ||
| const res = await fetchImpl(source.url, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: basicAuthHeader(source), | ||
| }, | ||
| body: JSON.stringify({ jsonrpc: '1.0', id: 'chaintip', method, params }), | ||
| signal: controller.signal, | ||
| }); | ||
| if (!res.ok) throw new Error(`chaintip: rpc ${method} returned HTTP ${res.status}`); | ||
| const body = await res.json(); | ||
| if (body && body.error) { | ||
| throw new Error(`chaintip: rpc ${method} error: ${JSON.stringify(body.error)}`); | ||
| } | ||
| return body.result; | ||
| } finally { | ||
| clearTimeout(t); | ||
| } | ||
| } | ||
| const fetchImpl = opts.fetchImpl || globalThis.fetch; | ||
| if (typeof fetchImpl !== 'function') { | ||
| throw new Error('chaintip: no fetch implementation available (Node >= 18 or pass opts.fetchImpl)'); | ||
| const [height, hash] = await Promise.all([ | ||
| rpc('getblockcount'), | ||
| rpc('getbestblockhash'), | ||
| ]); | ||
| if (!Number.isFinite(height) || height <= 0) { | ||
| throw new Error(`chaintip: rpc getblockcount returned invalid height: ${JSON.stringify(height)}`); | ||
| } | ||
| const timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : 3000; | ||
| if (typeof hash !== 'string' || !/^[0-9a-f]{64}$/.test(hash)) { | ||
| throw new Error(`chaintip: rpc getbestblockhash returned invalid hash: ${JSON.stringify(hash)}`); | ||
| } | ||
| return { | ||
| height, | ||
| hash, | ||
| observed_at: new Date().toISOString(), | ||
| }; | ||
| } | ||
| /** | ||
| * Fetch tip from mempool.space (the default source). Same shape as the RPC | ||
| * path. Preserved verbatim from the pre-RPC version so existing callers see | ||
| * identical behavior. | ||
| */ | ||
| async function fetchTipFromMempoolSpace(fetchImpl, timeoutMs) { | ||
| async function getText(url) { | ||
@@ -89,3 +158,3 @@ const controller = new AbortController(); | ||
| const value = { | ||
| return { | ||
| height, | ||
@@ -95,3 +164,67 @@ hash: hashText, | ||
| }; | ||
| cached = { value, expires_at: now + CACHE_TTL_MS }; | ||
| } | ||
| /** | ||
| * Fetch current Bitcoin tip height and hash. | ||
| * | ||
| * Returns `{ height, hash, observed_at }` where observed_at is an ISO-8601 | ||
| * timestamp of when the fetch landed on this host (not a block timestamp). | ||
| * Cached for 60 seconds in-process per source key. | ||
| * | ||
| * Default source: mempool.space HTTP endpoints. Same path as every prior | ||
| * release. Backwards-compatible: a caller who does not pass `opts.source` | ||
| * sees byte-identical behavior to the pre-0.7.0-beta.2 module. | ||
| * | ||
| * Opt-in RPC source: | ||
| * getChaintip({ | ||
| * source: { | ||
| * type: 'bitcoind-rpc', | ||
| * url: 'http://lightning.lan:8332', | ||
| * user: 'zeke', | ||
| * password: process.env.BITCOIN_RPC_PASSWORD, | ||
| * // or pre-encoded: authBasic: 'base64...' | ||
| * }, | ||
| * }); | ||
| * When set, the module POSTs JSON-RPC `getblockcount` + `getbestblockhash` | ||
| * to the given endpoint instead of hitting mempool.space. The returned | ||
| * envelope schema is unchanged — callers cannot tell which source produced | ||
| * the value, which is the whole point: the signed score payload stays | ||
| * wire-compatible with all existing verifiers. | ||
| * | ||
| * On fetch error, throws. Callers decide whether to degrade (for example, | ||
| * the identity-score server swallows chaintip errors into | ||
| * `bitcoin_tip_error` rather than failing the score). | ||
| * | ||
| * @param {object} [opts] | ||
| * @param {Function} [opts.fetchImpl] Injected fetch for tests. Defaults to globalThis.fetch. | ||
| * @param {number} [opts.timeoutMs] Per-request timeout in ms. Default 3000. | ||
| * @param {object} [opts.source] { type: 'mempool-space' | 'bitcoind-rpc', ...fields } | ||
| * @returns {Promise<{height: number, hash: string, observed_at: string}>} | ||
| */ | ||
| async function getChaintip(opts = {}) { | ||
| const now = Date.now(); | ||
| const source = opts.source || { type: 'mempool-space' }; | ||
| const key = sourceCacheKey(source); | ||
| const existing = cacheByKey.get(key); | ||
| if (existing && existing.expires_at > now) { | ||
| return existing.value; | ||
| } | ||
| const fetchImpl = opts.fetchImpl || globalThis.fetch; | ||
| if (typeof fetchImpl !== 'function') { | ||
| throw new Error('chaintip: no fetch implementation available (Node >= 18 or pass opts.fetchImpl)'); | ||
| } | ||
| const timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : 3000; | ||
| let value; | ||
| if (source.type === 'bitcoind-rpc') { | ||
| value = await fetchTipFromRpc(source, fetchImpl, timeoutMs); | ||
| } else if (source.type === 'mempool-space' || !source.type) { | ||
| value = await fetchTipFromMempoolSpace(fetchImpl, timeoutMs); | ||
| } else { | ||
| throw new Error(`chaintip: unknown source.type ${JSON.stringify(source.type)}`); | ||
| } | ||
| cacheByKey.set(key, { value, expires_at: now + CACHE_TTL_MS }); | ||
| return value; | ||
@@ -105,3 +238,3 @@ } | ||
| function resetChaintipCache() { | ||
| cached = null; | ||
| cacheByKey.clear(); | ||
| } | ||
@@ -108,0 +241,0 @@ |
61832
13.19%1061
13.11%