From 82aa804fd69bb03e043288c5d50564beee4d5544 Mon Sep 17 00:00:00 2001
From: J-Dog
Date: Fri, 21 Aug 2026 11:06:35 -0700
Subject: [PATCH 1/9] README: time-stamp the supported-chain roster
Bitcoin, Litecoin, and Dogecoin are where the platform runs today, not the
definition of it; the README says so where it names them.
---
README.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index b78f82b..43ae2a8 100644
--- a/README.md
+++ b/README.md
@@ -14,11 +14,11 @@
-Transaction extraction service for the XChain Platform. Polls cryptocurrency nodes (Bitcoin, Litecoin, Dogecoin) via JSON-RPC, parses every block, identifies XChain-encoded transactions, deobfuscates the embedded ACTION payloads using AES-128-CTR, and writes the raw decoded data to a MariaDB database for the indexer to process.
+Transaction extraction service for the XChain Platform. Polls the coin nodes of every supported chain (Bitcoin, Litecoin, and Dogecoin today) via JSON-RPC, parses every block, identifies XChain-encoded transactions, deobfuscates the embedded ACTION payloads using AES-128-CTR, and writes the raw decoded data to a MariaDB database for the indexer to process.
## Features
-- **Multi-chain support**: Bitcoin, Litecoin, and Dogecoin on mainnet, testnet, and regtest
+- **Multi-chain support**: Bitcoin, Litecoin, and Dogecoin today on mainnet, testnet, and regtest
- **AES-128-CTR deobfuscation**: derives key and IV from the first input's txid
- **Four encoding formats**: OP_RETURN, P2SH (reassembled from scriptSigs), P2WSH (reassembled from witness data), and 1-of-3 multisig
- **Chain-specific parsing**: Litecoin MWEB/HogEx flag stripping; Dogecoin AuxPoW header stripping
From 310102a1afc9227348728548e773997a7630ceb6 Mon Sep 17 00:00:00 2001
From: J-Dog
Date: Sat, 22 Aug 2026 15:21:51 -0700
Subject: [PATCH 2/9] feat(api): serve the live mempool over JSON-RPC
The mempool table is deliberately excluded from replication (it is
node-local, non-deterministic observation state), so an explorer serving
from synced replicas has no database path to pending actions. Add a
getmempool JSON-RPC method as that path: it returns the node's total
mempool tx count (snapshotted each poll cycle), the count of
action-carrying rows, and a bounded 500-row window of tx_hash / source /
data / first_seen. The method is TTL-cached so an unauthenticated
request burst cannot amplify into pooled-connection contention against
the block loop.
Pending rows also gain a first_seen timestamp (server-side insert
default; automatic additive migration) so clients can show when a
pending action was first observed.
---
CHANGELOG.md | 6 +
src/XChainDecoder.js | 12 ++
src/api.js | 57 +++++++
src/db.js | 44 +++++
src/sql/mempool_transactions.sql | 8 +-
.../2026-08-22-mempool-first-seen.sql | 44 +++++
test/unit/mempoolApiSurface.test.js | 161 ++++++++++++++++++
7 files changed, 331 insertions(+), 1 deletion(-)
create mode 100644 src/sql/migrations/2026-08-22-mempool-first-seen.sql
create mode 100644 test/unit/mempoolApiSurface.test.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4c2ed2..75a05ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Added
+- The API serves a `getmempool` method so remote explorers can read the live mempool, including the node's total unconfirmed count.
+- Mempool rows record when this decoder first observed them, applied as an automatic additive migration.
+
## [0.10.0] - 2026-08-18
Consensus-affecting changes in this release ship behind per-chain activation
diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js
index 6cfd686..60985ed 100644
--- a/src/XChainDecoder.js
+++ b/src/XChainDecoder.js
@@ -355,6 +355,13 @@ class XChainDecoder {
this._nodeHeightStaleLogged = false
this.mempoolInterval = null
this.mempoolBusy = false
+ // Node-mempool observation snapshot from the last updateMempool cycle:
+ // the coin node's TOTAL mempool tx count (getrawmempool length, XChain or
+ // not) and when it was taken. -1/null until the first successful poll.
+ // Read by the API's getmempool method so the explorer can show
+ // " / " without its own node RPC.
+ this.nodeMempoolTxCount = -1
+ this.nodeMempoolUpdatedAt = null
this.stopFlag = false
@@ -3259,6 +3266,11 @@ class XChainDecoder {
rawMempool = Array.from(new Set(rawMempoolUnordered))
.sort((a, b) => b.localeCompare(a))
+ // Snapshot the node's total mempool size for the API's getmempool
+ // method (deduped count, matching what this cycle actually processes).
+ this.nodeMempoolTxCount = rawMempool.length
+ this.nodeMempoolUpdatedAt = Date.now()
+
} catch (error) {
console.log(error)
console.log("There were problems getting the mempool, trying again later.", error)
diff --git a/src/api.js b/src/api.js
index 4326add..ea0c722 100644
--- a/src/api.js
+++ b/src/api.js
@@ -220,6 +220,10 @@ async function startApi(){
registerDecoderMetrics(observability.registry, decoder)
+ // getmempool's shared snapshot cache (see the method's comment). Held here so
+ // every request, whatever its limit, slices one cached 500-row window.
+ let getmempoolCache = null;
+
const jsonRpcController = {
// Function to check if xchain-decoder is up
async ping() {
@@ -299,6 +303,59 @@ async function startApi(){
node_block_index: status.node_height,
is_synced: decoder.isSynced()
};
+ },
+ // Current mempool snapshot for remote explorers. mempool_transactions is
+ // deliberately excluded from xchain-sync replication (node-local,
+ // non-deterministic observation), so an explorer serving from synced
+ // replicas has no DB path to pending actions; this method is that path.
+ // Returns the node's TOTAL mempool tx count (XChain or not, from the
+ // last updateMempool poll; -1 until one has run), the count of
+ // XChain-carrying rows, and a bounded row window (same 500-row cap and
+ // tx_hash ordering as the explorer's colocated-DB read). Rows are
+ // PRE-VALIDATION: the indexer can still reject them at confirmation.
+ //
+ // TTL-cached (default 5s, GETMEMPOOL_CACHE_MS) because this method, unlike
+ // its trivial siblings above, reads the DB: without the cache an
+ // unauthenticated request burst would amplify into pooled-connection
+ // contention against the block loop (the same hazard the batch guard
+ // below exists for). The full 500-row window is cached once and sliced
+ // per-request, so differing limits share one read. A poll-cycle-stale
+ // snapshot is fine: updateMempool itself only rewrites every 60s.
+ async getmempool(params) {
+ const ttl = parseInt(process.env.GETMEMPOOL_CACHE_MS, 10) || 5000;
+ const now = Date.now();
+ const db = decoder.mempoolDb || decoder.db;
+ if (!getmempoolCache || (now - getmempoolCache.t) >= ttl) {
+ let rows = [], total = 0;
+ if (db) {
+ try {
+ rows = await db.getMempoolTransactions(500);
+ total = await db.getMempoolTransactionCount();
+ } catch (err) {
+ // Serve the stale snapshot if we have one; a mempool read
+ // must never surface as an API error to remote explorers.
+ console.error('getmempool: mempool read failed:', err);
+ rows = getmempoolCache ? getmempoolCache.rows : [];
+ total = getmempoolCache ? getmempoolCache.total : 0;
+ }
+ }
+ getmempoolCache = { t: now, rows, total };
+ }
+ const limit = Math.max(1, Math.min(parseInt(params && params.limit, 10) || 500, 500));
+ return {
+ node_tx_count: decoder.nodeMempoolTxCount,
+ node_updated_at: decoder.nodeMempoolUpdatedAt,
+ total: getmempoolCache.total,
+ rows: getmempoolCache.rows.slice(0, limit).map(r => ({
+ tx_hash: r.tx_hash,
+ source: r.source,
+ // TEXT can come back as a Buffer depending on driver options;
+ // normalize so the JSON body always carries the UTF-8 string.
+ data: Buffer.isBuffer(r.data) ? r.data.toString('utf8') : r.data,
+ first_seen: (r.first_seen instanceof Date) ? Math.floor(r.first_seen.getTime() / 1000)
+ : (r.first_seen != null ? r.first_seen : null)
+ }))
+ };
}
}
diff --git a/src/db.js b/src/db.js
index 74a28b9..2bc6343 100644
--- a/src/db.js
+++ b/src/db.js
@@ -1748,6 +1748,50 @@ class Database {
}
}
+ // Bounded read of the current mempool snapshot for the API's getmempool
+ // method. Same raw-string columns the explorer's colocated-DB path reads
+ // (tx_hash/source/data), plus first_seen (2026-08-22-mempool-first-seen.sql).
+ // ORDER BY the unique-indexed tx_hash: the table has no primary key and is
+ // rewritten row-by-row every poll cycle, so a bare LIMIT would return a
+ // scan-order subset that churns between polls; callers diff/page this
+ // window as a stable snapshot. Capped at 500 like the explorer's own
+ // getDecoderMempoolRows window.
+ async getMempoolTransactions(limit) {
+ const max = Math.max(1, Math.min(Number(limit) || 200, 500))
+ const query = `
+ SELECT tx_hash, source, data, first_seen
+ FROM mempool_transactions
+ ORDER BY tx_hash
+ LIMIT ${max};
+ `;
+ let connection = await this.getConnection()
+ const ownLease = (this.transactionConnection == null)
+ try {
+ const rows = await connection.query(query)
+ return rows || []
+ } finally {
+ if (ownLease) {
+ await connection.release()
+ }
+ }
+ }
+
+ // Total mempool_transactions row count (the XChain-carrying subset of the
+ // node mempool), companion to the bounded window above so getmempool can
+ // report a true total when the table runs past the 500-row cap.
+ async getMempoolTransactionCount() {
+ let connection = await this.getConnection()
+ const ownLease = (this.transactionConnection == null)
+ try {
+ const rows = await connection.query('SELECT COUNT(*) AS count FROM mempool_transactions;')
+ return (rows && rows.length) ? Number(rows[0].count) : 0
+ } finally {
+ if (ownLease) {
+ await connection.release()
+ }
+ }
+ }
+
//This is only used in tests
async dropDatabase(){
console.log("Droping database")
diff --git a/src/sql/mempool_transactions.sql b/src/sql/mempool_transactions.sql
index 97f56e8..9260aa0 100644
--- a/src/sql/mempool_transactions.sql
+++ b/src/sql/mempool_transactions.sql
@@ -27,7 +27,13 @@ CREATE TABLE mempool_transactions (
data MEDIUMTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, -- Decoded data
-- Mirrors transactions.raw_data so the pending row carries the encoder's second push
-- (FILE bytes, gated ciphertext) instead of only revealing it at confirmation.
- raw_data MEDIUMBLOB
+ raw_data MEDIUMBLOB,
+ -- When THIS decoder first observed the tx in its node's mempool. Local,
+ -- non-deterministic observation time (like everything in this table); the
+ -- explorer's pending-actions feed renders it as the row's Time column.
+ -- Server-side default so updateMempool's insert-once/delete-on-departure
+ -- cycle stamps it with no writer change. Migration: 2026-08-22-mempool-first-seen.sql.
+ first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
-- Mempool rows hold raw strings rather than index_addresses/index_transactions ids.
diff --git a/src/sql/migrations/2026-08-22-mempool-first-seen.sql b/src/sql/migrations/2026-08-22-mempool-first-seen.sql
new file mode 100644
index 0000000..3dfbb30
--- /dev/null
+++ b/src/sql/migrations/2026-08-22-mempool-first-seen.sql
@@ -0,0 +1,44 @@
+--********************************************************************
+--
+-- Copyright © 2025-2026 Dankest, LLC
+-- Based on XChain Platform by Dankest, LLC - https://dankest.llc
+--
+-- SPDX-License-Identifier: AGPL-3.0-or-later
+--
+-- This file is part of XChain Platform. Licensed under the GNU Affero
+-- General Public License v3.0 or later; see LICENSE.md. A commercial
+-- license (without AGPL source-disclosure terms) is available -
+-- contact legal@dankest.llc.
+--
+--********************************************************************
+
+-- xchain:migration mode=auto
+-- (auto: purely additive column with a server-side default; no data conversion,
+-- no backfill, no destructive change. mempool_transactions is small and
+-- transient (node mempool size, rewritten row-by-row every poll cycle), so the
+-- ALTER's table rebuild is cheap. Idempotent via ADD COLUMN IF NOT EXISTS.)
+--
+-- Migration: mempool_transactions ADD first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+--
+-- WHY
+-- ---
+-- The explorer's mempool view shows pending actions with the same columns as
+-- confirmed history (block / time / action / details), and "time" for a pending
+-- row is when THIS decoder first observed the tx in its node's mempool. The
+-- table had no timestamp at all, so the feed had nothing to render. updateMempool
+-- inserts a row once on first observation and deletes it when the tx leaves the
+-- node mempool (deleteAndCompareTxsNotInList), so a server-side insert default
+-- gives every row a stable first-seen time with no writer change.
+--
+-- CONSENSUS NOTE
+-- --------------
+-- first_seen is LOCAL OBSERVATION TIME ONLY. Mempool observation is per-node and
+-- non-deterministic by design (this whole table is excluded from xchain-sync
+-- replication), and nothing here enters any consensus hash preimage.
+--
+-- IDEMPOTENT: ADD COLUMN IF NOT EXISTS is a no-op once the column exists, and
+-- the schema_migrations ledger records this file once per DB. Fresh installs get
+-- the column from src/sql/mempool_transactions.sql, so on those this migration
+-- is a no-op. Applies automatically at decoder startup.
+
+ALTER TABLE mempool_transactions ADD COLUMN IF NOT EXISTS first_seen TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
diff --git a/test/unit/mempoolApiSurface.test.js b/test/unit/mempoolApiSurface.test.js
new file mode 100644
index 0000000..a519654
--- /dev/null
+++ b/test/unit/mempoolApiSurface.test.js
@@ -0,0 +1,161 @@
+/*********************************************************************
+ *
+ * Copyright © 2025-2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC - https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ *********************************************************************/
+
+'use strict';
+
+// The remote-mempool surface added for the explorer's replica deployments:
+// mempool_transactions is deliberately excluded from xchain-sync replication
+// (node-local, non-deterministic), so an explorer serving from synced replicas
+// can only see pending actions through the decoder's own API. These tests pin
+// the three pieces that surface provides:
+// 1. the node-mempool observation snapshot updateMempool records,
+// 2. the bounded DB reads the API serves rows from,
+// 3. the api.js getmempool method's contract (cache, clamp, field mapping).
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const sinon = require('sinon');
+const XChainDecoder = require('../../src/XChainDecoder');
+const Database = require('../../src/db.js');
+
+function makeDecoder() {
+ return new XChainDecoder(
+ 'bitcoin-regtest', 'h', '0', 'db', 'u', 'p', 'h', '0', 'u', 'p', false, null
+ );
+}
+
+function withConn(queryStub) {
+ const conn = {
+ query: queryStub || sinon.stub().resolves([]),
+ release: sinon.stub().resolves(),
+ };
+ const pool = { getConnection: sinon.stub().resolves(conn) };
+ return { pool, conn };
+}
+
+describe('node-mempool observation snapshot', () => {
+ afterEach(() => sinon.restore());
+
+ it('starts unknown: -1 count / null timestamp until the first poll', () => {
+ const decoder = makeDecoder();
+ assert.strictEqual(decoder.nodeMempoolTxCount, -1);
+ assert.strictEqual(decoder.nodeMempoolUpdatedAt, null);
+ });
+
+ it('updateMempool records the DEDUPED node mempool size and a timestamp', async () => {
+ const decoder = makeDecoder();
+ sinon.stub(decoder.connector, 'getRawMempool').resolves(['bb', 'aa', 'aa']);
+ sinon.stub(decoder.connector, 'getRawTransactions').resolves([]);
+ decoder.mempoolDb = {
+ deleteAndCompareTxsNotInList: sinon.stub().resolves({ transactionsDeleted: 0 }),
+ };
+ await decoder.updateMempool();
+ assert.strictEqual(decoder.nodeMempoolTxCount, 2); // 'aa' deduped
+ assert.ok(typeof decoder.nodeMempoolUpdatedAt === 'number');
+ assert.strictEqual(decoder.mempoolBusy, false);
+ });
+
+ it('a failed getrawmempool leaves the previous snapshot standing', async () => {
+ const decoder = makeDecoder();
+ decoder.nodeMempoolTxCount = 5;
+ decoder.nodeMempoolUpdatedAt = 12345;
+ sinon.stub(decoder.connector, 'getRawMempool').rejects(new Error('node down'));
+ await decoder.updateMempool();
+ assert.strictEqual(decoder.nodeMempoolTxCount, 5);
+ assert.strictEqual(decoder.nodeMempoolUpdatedAt, 12345);
+ assert.strictEqual(decoder.mempoolBusy, false);
+ });
+});
+
+describe('Database#getMempoolTransactions()', () => {
+ afterEach(() => sinon.restore());
+
+ it('reads the raw-string columns + first_seen in tx_hash order with a clamped limit', async () => {
+ const db = new Database('127.0.0.1', 3306, 'xchain_btc', 'u', 'p');
+ const row = { tx_hash: 'aa', source: 's', data: 'MINT|0|TOK|1', first_seen: new Date() };
+ const q = sinon.stub().resolves([row]);
+ const { pool, conn } = withConn(q);
+ db.pool = pool;
+ const rows = await db.getMempoolTransactions(9999);
+ assert.deepStrictEqual(rows, [row]);
+ const sql = q.firstCall.args[0];
+ assert.ok(sql.includes('tx_hash, source, data, first_seen'));
+ // No PK and the table is rewritten every poll cycle: the window must be
+ // keyed on the unique tx_hash index to be a stable snapshot, and the
+ // limit clamps to the same 500-row cap the explorer window uses.
+ assert.ok(/ORDER BY tx_hash\s+LIMIT 500/.test(sql));
+ assert.ok(conn.release.calledOnce);
+ });
+
+ it('releases the connection even when the query throws', async () => {
+ const db = new Database('127.0.0.1', 3306, 'xchain_btc', 'u', 'p');
+ const q = sinon.stub().rejects(new Error('boom'));
+ const { pool, conn } = withConn(q);
+ db.pool = pool;
+ await assert.rejects(() => db.getMempoolTransactions(10));
+ assert.ok(conn.release.calledOnce);
+ });
+});
+
+describe('Database#getMempoolTransactionCount()', () => {
+ afterEach(() => sinon.restore());
+
+ it('returns the count as a Number (BigInt-safe) and 0 on an empty result', async () => {
+ const db = new Database('127.0.0.1', 3306, 'xchain_btc', 'u', 'p');
+ const q = sinon.stub().resolves([{ count: 7n }]);
+ const { pool } = withConn(q);
+ db.pool = pool;
+ assert.strictEqual(await db.getMempoolTransactionCount(), 7);
+
+ const q2 = sinon.stub().resolves([]);
+ db.pool = withConn(q2).pool;
+ assert.strictEqual(await db.getMempoolTransactionCount(), 0);
+ });
+});
+
+// api.js builds its JSON-RPC controller inside startApi() (it is not exported),
+// so the getmempool contract is pinned at the source level, the same way the
+// explorer pins this repo's mempool INSERT site: the method must exist, consult
+// its TTL cache before the DB, clamp the row window, and map the decoder's
+// node-mempool snapshot fields into the response.
+describe('api.js getmempool method (source pin)', () => {
+ const src = fs.readFileSync(path.join(__dirname, '../../src/api.js'), 'utf8');
+
+ it('exposes getmempool on the JSON-RPC controller', () => {
+ assert.ok(/async getmempool\(/.test(src), 'getmempool method missing from api.js');
+ });
+
+ it('serves from a TTL cache so an unauthenticated burst cannot amplify into DB reads', () => {
+ const at = src.indexOf('async getmempool(');
+ const body = src.slice(at, at + 3000);
+ assert.ok(body.includes('GETMEMPOOL_CACHE_MS'), 'getmempool lost its TTL cache knob');
+ assert.ok(body.includes('getmempoolCache'), 'getmempool no longer consults the shared cache');
+ });
+
+ it('reads rows via the bounded DB helpers and clamps the per-request limit to 500', () => {
+ const at = src.indexOf('async getmempool(');
+ const body = src.slice(at, at + 3000);
+ assert.ok(body.includes('getMempoolTransactions(500)'), 'getmempool must read the full bounded window once');
+ assert.ok(body.includes('getMempoolTransactionCount()'), 'getmempool must report the true total');
+ assert.ok(/Math\.min\(parseInt\(params && params\.limit, 10\) \|\| 500, 500\)/.test(body), 'per-request limit clamp missing');
+ });
+
+ it('maps the node-mempool observation snapshot into the response', () => {
+ const at = src.indexOf('async getmempool(');
+ const body = src.slice(at, at + 3000);
+ assert.ok(body.includes('decoder.nodeMempoolTxCount'), 'node_tx_count no longer sourced from the observation snapshot');
+ assert.ok(body.includes('decoder.nodeMempoolUpdatedAt'), 'node_updated_at no longer sourced from the observation snapshot');
+ });
+});
From e45018470d47ac5cffdd7244843dca36a946d7ee Mon Sep 17 00:00:00 2001
From: J-Dog
Date: Sat, 22 Aug 2026 15:49:11 -0700
Subject: [PATCH 3/9] fix(api): count and serve only action-carrying mempool
rows
The mempool table holds a row for EVERY transaction the decoder observes
in its node's mempool, not just ones carrying an action: the shared
storage gate blanks the payload to an empty string when a money-bearing
transaction's action was invalid or unknown, which on a public chain is
nearly all of them (measured on testnet: 32 of 32 rows).
So the unfiltered count getmempool reported was the size of the whole
node mempool, which a consumer publishing it as the pending-action count
turns into every unrelated payment on the chain reading as a pending
action. The unfiltered row window had a worse failure: on a busy chain
all 500 slots fill with actionless rows, so the feed renders empty while
real pending actions sit deeper in the table.
Both now filter to rows that carry a payload, matching what consumers
already keep after decoding.
---
src/db.js | 24 ++++++++++++++++++++----
test/unit/mempoolApiSurface.test.js | 10 ++++++++++
2 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/src/db.js b/src/db.js
index 2bc6343..5f66b15 100644
--- a/src/db.js
+++ b/src/db.js
@@ -1756,11 +1756,22 @@ class Database {
// scan-order subset that churns between polls; callers diff/page this
// window as a stable snapshot. Capped at 500 like the explorer's own
// getDecoderMempoolRows window.
+ //
+ // ACTION-CARRYING ROWS ONLY. This table holds a row for EVERY mempool tx the
+ // decoder observed, not just XChain ones: buildStoredActionRecord blanks
+ // `data` to '' (never NULL) for a money-bearing tx whose ACTION was invalid
+ // or unknown, which on a public chain is nearly all of them (measured on BTC
+ // testnet 2026-08-22: 32 of 32 rows). An unfiltered window is useless to the
+ // consumer, because on a busy chain all 500 slots fill with actionless rows
+ // and the feed renders empty while real pending actions sit deeper in the
+ // table. Consumers drop these rows at decode time anyway, so filter here,
+ // where the LIMIT is applied.
async getMempoolTransactions(limit) {
const max = Math.max(1, Math.min(Number(limit) || 200, 500))
const query = `
SELECT tx_hash, source, data, first_seen
FROM mempool_transactions
+ WHERE data IS NOT NULL AND data != ''
ORDER BY tx_hash
LIMIT ${max};
`;
@@ -1776,14 +1787,19 @@ class Database {
}
}
- // Total mempool_transactions row count (the XChain-carrying subset of the
- // node mempool), companion to the bounded window above so getmempool can
- // report a true total when the table runs past the 500-row cap.
+ // Count of pending ACTION-carrying txs, companion to the bounded window
+ // above so getmempool can report a true total when the matching set runs
+ // past the 500-row cap. Carries the same `data != ''` filter and for the
+ // same reason (see getMempoolTransactions): an unfiltered COUNT(*) here is
+ // the size of the whole node mempool, so publishing it as the XChain
+ // unconfirmed count reports every unrelated payment on the chain as a
+ // pending XChain action.
async getMempoolTransactionCount() {
let connection = await this.getConnection()
const ownLease = (this.transactionConnection == null)
try {
- const rows = await connection.query('SELECT COUNT(*) AS count FROM mempool_transactions;')
+ const rows = await connection.query(
+ "SELECT COUNT(*) AS count FROM mempool_transactions WHERE data IS NOT NULL AND data != '';")
return (rows && rows.length) ? Number(rows[0].count) : 0
} finally {
if (ownLease) {
diff --git a/test/unit/mempoolApiSurface.test.js b/test/unit/mempoolApiSurface.test.js
index a519654..b9b73bc 100644
--- a/test/unit/mempoolApiSurface.test.js
+++ b/test/unit/mempoolApiSurface.test.js
@@ -92,6 +92,11 @@ describe('Database#getMempoolTransactions()', () => {
assert.deepStrictEqual(rows, [row]);
const sql = q.firstCall.args[0];
assert.ok(sql.includes('tx_hash, source, data, first_seen'));
+ // Action-carrying rows only: the table holds a row for EVERY mempool tx
+ // (data blanked to '' when the tx carried no valid ACTION), so an
+ // unfiltered window fills with actionless rows on a busy chain and the
+ // consumer's feed renders empty. Measured on BTC testnet: 32 of 32.
+ assert.ok(/WHERE data IS NOT NULL AND data != ''/.test(sql), 'window must filter to action-carrying rows');
// No PK and the table is rewritten every poll cycle: the window must be
// keyed on the unique tx_hash index to be a stable snapshot, and the
// limit clamps to the same 500-row cap the explorer window uses.
@@ -118,6 +123,11 @@ describe('Database#getMempoolTransactionCount()', () => {
const { pool } = withConn(q);
db.pool = pool;
assert.strictEqual(await db.getMempoolTransactionCount(), 7);
+ // Counts only action-carrying rows. An unfiltered COUNT(*) is the size
+ // of the whole node mempool, which would report every unrelated payment
+ // on the chain as a pending XChain action.
+ assert.ok(/WHERE data IS NOT NULL AND data != ''/.test(q.firstCall.args[0]),
+ 'count must filter to action-carrying rows');
const q2 = sinon.stub().resolves([]);
db.pool = withConn(q2).pool;
From e08b9fb0905c419c96b57cf6c54dffb6ef820620 Mon Sep 17 00:00:00 2001
From: J-Dog
Date: Sun, 23 Aug 2026 16:30:30 -0700
Subject: [PATCH 4/9] Declare the startup-asserted migrations to the deploy
tool
This service asserts three schema shapes at startup and crash-loops when
one is unmet, but none of those migrations said so in a way the deploy
tool could read, so a fleet upgrade discovered the requirement as an
outage instead of a refusal.
Each of the three now carries the deploy-precondition tag in its header,
and a registry names the assertion, the file and the symptom so the
error an operator reads points at the exact file. A parity test keeps
the halves in step: every registered entry must exist, be gated, and
carry the tag.
Tagging alone would have refused every database built fresh from the
schema files, which satisfies all three by construction while recording
none of them. The two shape preconditions that were missing are added
alongside, so such a database records what is already true instead of
being turned away.
The tags are header comments; no executable statement changes. The
recorded checksums move with them, so the immutability guard heals
forward rather than tripping fleet-wide.
---
CHANGELOG.md | 3 +
src/db.js | 169 ++++++++++-
...026-06-13-dispensers-expiration-bigint.sql | 9 +-
.../2026-07-24-pubkeys-widen-uncompressed.sql | 9 +-
.../2026-08-10-action-data-utf8mb4.sql | 9 +-
test/unit/migration-preconditions.test.js | 283 ++++++++++++++++++
6 files changed, 471 insertions(+), 11 deletions(-)
create mode 100644 test/unit/migration-preconditions.test.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75a05ee..d65103c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Fixed
+- The migrations this service asserts at startup are now declared to the deploy tool, and one whose end state already holds is recorded as applied without re-running its statement.
+
### Added
- The API serves a `getmempool` method so remote explorers can read the live mempool, including the node's total unconfirmed count.
- Mempool rows record when this decoder first observed them, applied as an automatic additive migration.
diff --git a/src/db.js b/src/db.js
index 5f66b15..3f1fc89 100644
--- a/src/db.js
+++ b/src/db.js
@@ -548,7 +548,8 @@ class Database {
throw new Error(
'dispensers.expiration has type ' + columnType.toUpperCase() + ' but BIGINT UNSIGNED is required ' +
'(FROM_UNIXTIME/DATETIME silently NULLs any expiration past 2038, which the decoder then never expires). ' +
- 'Run the pending migration: node src/migrate.js --file 2026-06-13-dispensers-expiration-bigint.sql'
+ 'Run the pending migration: node src/migrate.js --file ' +
+ Database.startupAssertedMigrationFile('_assertDispenserExpirationIsBigintUnsigned')
);
}
if(dataType !== 'bigint'){
@@ -602,7 +603,8 @@ class Database {
throw new Error(
'pubkeys.pubkey holds ' + len + ' chars but VARCHAR(' + UNCOMPRESSED_PUBKEY_HEX_LENGTH + ') is required ' +
'for uncompressed keys; narrower silently NULLs or truncates the source_pubkey seam field. ' +
- 'Run the pending migration: node src/migrate.js'
+ 'Run the pending migration: node src/migrate.js --file ' +
+ Database.startupAssertedMigrationFile('_assertPubkeyColumnIsUncompressedWide')
);
}
} finally {
@@ -641,7 +643,8 @@ class Database {
String(row.tbl) + '.data uses charset ' + cs + ' but utf8mb4 is required; a non-BMP ' +
'ACTION (e.g. an emoji MEMO) is rejected with errno 1366 and the fee-paid transaction ' +
'is quarantined with no ACTION row, diverging this node from a migrated one. ' +
- 'Run the pending migration: node src/migrate.js'
+ 'Run the pending migration: node src/migrate.js --file ' +
+ Database.startupAssertedMigrationFile('_assertActionDataIsUtf8mb4')
);
}
}
@@ -2687,12 +2690,14 @@ Database.MIGRATION_CHECKSUM_REBASELINES = {
],
to: '1d8406192690e5a754ec9430fcd9115e907f34944f340a70b776166a62f83868', // ec36bd4 (HEAD)
},
- // Comment-only edit: the header claimed mode=manual left the file
+ // Comment-only edits: the header claimed mode=manual left the file
// "pending and harmless on fresh DBs" and that IF [NOT] EXISTS made a partial
// run resumable. Both were false and both invited the corrupting blanket run,
- // so the header now names MIGRATION_PRECONDITIONS below as the actual guard.
- // The four statements are unchanged since authorship (63fc384): stripping
- // `--` comment lines and blank lines leaves the identical residue
+ // so the header names MIGRATION_PRECONDITIONS below as the actual guard, and
+ // now also carries the `deploy-precondition=required` tag so the deploy tool can
+ // see the same requirement from a cloned source tree. The four statements are
+ // unchanged since authorship (63fc384): stripping `--` comment lines and blank
+ // lines leaves the identical residue
// 820a0b2ae5b662a4e963dd2301f6ac86d2f67feaa6b59527c23fabec3c1a678c at every
// revision pinned here.
'2026-06-13-dispensers-expiration-bigint.sql': {
@@ -2700,8 +2705,27 @@ Database.MIGRATION_CHECKSUM_REBASELINES = {
'8b163db63932ec7940fc0c4ff83abb6a52d27ab4a192c377ce5195c3ca4b969f', // 63fc384
'c4d622adc34b3190a7cc43954b4c815a3c79bb6c6b7374be39c16d66454d1549', // ec36bd4 (license header)
'44901ce7272347e6665ffe29655dbd7b8f3e45ba58b26671e50d07c0c629caef', // header correction
+ '2e20aceb9a446f03ff8ef7a9fd2cc6dede722c30610de57c0d1ef25a455b4dca', // comment tidy
],
- to: '2e20aceb9a446f03ff8ef7a9fd2cc6dede722c30610de57c0d1ef25a455b4dca', // comment tidy (HEAD)
+ to: '0e871ed4aea8649d6a5ffe866d78af38ceee37e5cd07d651287cfe1e8c99c8b2', // deploy-precondition tag (HEAD)
+ },
+ // Comment-only edit: added the `deploy-precondition=required` header tag (and the
+ // comment explaining it) so the deploy tool can see, from the source tree it is
+ // about to deploy, that this migration is a startup-assertion precondition. The
+ // single ALTER is unchanged since authorship; this is the file's only prior
+ // committed revision.
+ '2026-07-24-pubkeys-widen-uncompressed.sql': {
+ from: '2dccc278c37935e1e5b0fc2b0a8c4514a24d5381936a1d9bc1fc5ce8d8473c43',
+ to: '156fca3b75b332ef099e8dd5d28624d9ebc26d34e143e37e1f9503b6c0da0c1d', // deploy-precondition tag (HEAD)
+ },
+ // Comment-only edit: added the `deploy-precondition=required` header tag (and the
+ // comment explaining it) so the deploy tool can see, from the source tree it is
+ // about to deploy, that this migration is a startup-assertion precondition. The
+ // two ALTER statements are unchanged since authorship; this is the file's only
+ // prior committed revision.
+ '2026-08-10-action-data-utf8mb4.sql': {
+ from: '027a643d3ff0be087b38889f947fdde2b4d8c696682c3b3642f288553f419068',
+ to: '0b3b2fefb780da1fb96a0d5518967b67b215676cc1ac02efc08ec1672d9091b2', // deploy-precondition tag (HEAD)
},
// Comment-only edit: the header prose was tidied and a stale operator note
// dropped. The executable statements are unchanged since a0f826b, which is
@@ -2784,6 +2808,58 @@ Database.MIGRATION_PRECONDITIONS = {
', so there is no DATETIME to convert and UNIX_TIMESTAMP() would NULL every row.';
}
},
+
+ // Widens pubkeys.pubkey to hold an uncompressed key (130 hex chars). It is
+ // mode=manual, so it stays PENDING on a database created from the current
+ // src/sql/pubkeys.sql (already VARCHAR(130) or wider), and a fresh install has no
+ // narrow column to widen. Baseline only while the live column is already 130
+ // characters or more, the same threshold _assertPubkeyColumnIsUncompressedWide
+ // enforces at startup.
+ //
+ // Absent table/column, or an unreadable/NULL length, is deliberately NOT
+ // baselined: that state needs an operator, and the startup assertion fails
+ // closed on it.
+ '2026-07-24-pubkeys-widen-uncompressed.sql': {
+ sql: "SELECT CHARACTER_MAXIMUM_LENGTH AS len FROM information_schema.columns " +
+ "WHERE table_schema = ? AND table_name = 'pubkeys' AND column_name = 'pubkey'",
+ skipWhen: (rows) => {
+ // No column, or a length we could not read: never baseline on an absent
+ // answer, let the file speak for itself and the assertion fail closed after it.
+ if(!rows.length || rows[0].len == null) return null;
+ const len = Number(rows[0].len);
+ if(Number.isNaN(len)) return null;
+ if(len >= 130) return 'pubkeys.pubkey is already ' + len + ' characters wide, so there is no narrow column to widen.';
+ return null;
+ }
+ },
+
+ // Widens transactions.data and mempool_transactions.data from utf8mb3 to utf8mb4.
+ // It is mode=manual (a charset conversion rewrites every row), so it stays PENDING
+ // on a database created from the current src/sql (already utf8mb4), and a fresh
+ // install has no utf8mb3 column to convert. Baseline only while BOTH columns
+ // already carry the utf8mb4 charset, the same query and per-column condition
+ // _assertActionDataIsUtf8mb4 enforces at startup.
+ //
+ // A half-converted pair (one column already utf8mb4, the other not) is
+ // deliberately NOT baselined: the file still has real work to do on the lagging
+ // column, so it must run. Either column absent, or an unreadable/NULL charset, is
+ // also NOT baselined: that state needs an operator, and the startup assertion
+ // fails closed on it.
+ '2026-08-10-action-data-utf8mb4.sql': {
+ sql: "SELECT table_name AS tbl, character_set_name AS cs FROM information_schema.columns " +
+ "WHERE table_schema = ? AND column_name = 'data' AND table_name IN ('transactions', 'mempool_transactions')",
+ skipWhen: (rows) => {
+ // Fewer than both columns found: never baseline on an incomplete answer,
+ // let the file run and the assertion fail closed on whichever column it
+ // could not see.
+ if(rows.length < 2) return null;
+ for(const row of rows){
+ const cs = row.cs == null ? null : String(row.cs).toLowerCase();
+ if(cs !== 'utf8mb4') return null;
+ }
+ return 'transactions.data and mempool_transactions.data are already utf8mb4, so there is no utf8mb3 column left to convert.';
+ }
+ },
};
// Backdating guard for the auto-apply path, mirroring xchain-indexer/src/db.js. Apply
@@ -2819,4 +2895,81 @@ Database.backdatedFrontierViolation = function(pendingName, appliedNames){
return (String(pendingName) < frontier) ? frontier : null;
};
+// The header token that marks a migration as a DEPLOY PRECONDITION: code in this
+// tree asserts it at startup, so a build carrying that assertion must not be
+// deployed against a database that has not applied it. It rides on the existing
+// `-- xchain:migration` directive line, next to `mode=`:
+//
+// -- xchain:migration mode=manual deploy-precondition=required
+//
+// Only a mode=manual file needs it. An `auto` file applies itself at the first
+// startup that sees it, so it can never be the missing precondition.
+Database.DEPLOY_PRECONDITION_TAG = 'deploy-precondition=required';
+
+// Migrations this tree ASSERTS at startup: the service refuses to run when the
+// target database has not applied them.
+//
+// WHY THIS LIST EXISTS
+// --------------------
+// A v0.10.0 fleet deploy put five of nine decoders into Restarting(1) crash-loops.
+// The three startup assertions above (_assertDispenserExpirationIsBigintUnsigned,
+// _assertPubkeyColumnIsUncompressedWide, _assertActionDataIsUtf8mb4) each require a
+// mode=manual migration, and none of the three migration files carried a header the
+// deploy tool could read, so nothing checked the precondition at deploy time and the
+// crash-loop itself was the only thing that surfaced the requirement.
+//
+// The registry is the in-code half of the fix. The machine-readable half is the
+// DEPLOY_PRECONDITION_TAG in each listed migration's own header, which the deploy
+// tool reads out of the source tree it is about to deploy and checks against the
+// target DB's schema_migrations BEFORE the container is recreated.
+// test/unit/migration-preconditions.test.js keeps the halves in step: every entry
+// here must exist, be mode=manual, and carry the tag.
+//
+// ADDING A STARTUP ASSERTION: register it here and tag its migration file, or the
+// next fleet deploy discovers the requirement as a crash-loop again.
+Database.STARTUP_ASSERTED_MIGRATIONS = [
+ {
+ file: '2026-06-13-dispensers-expiration-bigint.sql',
+ assertion: '_assertDispenserExpirationIsBigintUnsigned',
+ symptom: 'Fatal decoder error: dispensers.expiration has type DATETIME but BIGINT UNSIGNED is required'
+ },
+ {
+ file: '2026-07-24-pubkeys-widen-uncompressed.sql',
+ assertion: '_assertPubkeyColumnIsUncompressedWide',
+ symptom: 'Fatal decoder error: pubkeys.pubkey holds 66 chars but VARCHAR(130) is required'
+ },
+ {
+ file: '2026-08-10-action-data-utf8mb4.sql',
+ assertion: '_assertActionDataIsUtf8mb4',
+ symptom: 'Fatal decoder error: transactions.data uses charset utf8mb3 but utf8mb4 is required'
+ },
+];
+
+// Registry lookup by assertion method name. Throws rather than returning undefined:
+// an assertion that names a migration nobody registered would otherwise render as
+// "--file undefined" in the very error an operator reads mid-outage.
+Database.startupAssertedMigrationFile = function(assertion){
+ const entry = Database.STARTUP_ASSERTED_MIGRATIONS.find(m => m.assertion === assertion);
+ if(!entry) throw new Error('startupAssertedMigrationFile: ' + assertion +
+ ' is not registered in Database.STARTUP_ASSERTED_MIGRATIONS');
+ return entry.file;
+};
+
+// Does this migration file's header declare itself a deploy precondition?
+// Prologue-anchored exactly like _migrationMode (the scan stops at the first
+// non-blank, non-comment line), so a token buried in body prose or a data literal
+// cannot arm it. Pure string logic, unit-tested directly.
+//
+// Twin: the deploy tool carries the same parser, because it reads these files from a
+// source tree it has only cloned and cannot require this module. Keep the two in step.
+Database.migrationDeclaresDeployPrecondition = function(raw){
+ const prologue = [];
+ for(const line of String(raw).split('\n')){
+ const trimmed = line.trim();
+ if(trimmed === '' || trimmed.startsWith('--')){ prologue.push(line); continue; }
+ break;
+ }
+ return /^\s*--\s*xchain:migration\b[^\n]*\bdeploy-precondition\s*=\s*required\b/im.test(prologue.join('\n'));
+};
+
module.exports = Database
\ No newline at end of file
diff --git a/src/sql/migrations/2026-06-13-dispensers-expiration-bigint.sql b/src/sql/migrations/2026-06-13-dispensers-expiration-bigint.sql
index d69d943..0085aef 100644
--- a/src/sql/migrations/2026-06-13-dispensers-expiration-bigint.sql
+++ b/src/sql/migrations/2026-06-13-dispensers-expiration-bigint.sql
@@ -12,7 +12,14 @@
--
--********************************************************************
--- xchain:migration mode=manual
+-- xchain:migration mode=manual deploy-precondition=required
+-- (deploy-precondition=required: src/db.js asserts this column's type at startup
+-- (_assertDispenserExpirationIsBigintUnsigned), so code carrying that assertion
+-- cannot run against a database that has not applied this file; it crash-loops on
+-- boot. The deploy tool reads this tag out of the source tree it is about to deploy
+-- and refuses the deploy while the target DB's schema_migrations lacks this row,
+-- instead of the crash-loop being what surfaces the requirement.)
+--
-- (manual: a one-time column TYPE change with a value conversion. Run with the
-- decoder stopped, take a backup first; see HOW TO RUN below.)
-- Migration: dispensers.expiration DATETIME -> BIGINT UNSIGNED (raw unix seconds).
diff --git a/src/sql/migrations/2026-07-24-pubkeys-widen-uncompressed.sql b/src/sql/migrations/2026-07-24-pubkeys-widen-uncompressed.sql
index 2368603..cd2a3c7 100644
--- a/src/sql/migrations/2026-07-24-pubkeys-widen-uncompressed.sql
+++ b/src/sql/migrations/2026-07-24-pubkeys-widen-uncompressed.sql
@@ -12,7 +12,14 @@
--
--********************************************************************
--- xchain:migration mode=manual
+-- xchain:migration mode=manual deploy-precondition=required
+-- (deploy-precondition=required: src/db.js asserts this column's width at startup
+-- (_assertPubkeyColumnIsUncompressedWide), so code carrying that assertion cannot run
+-- against a database that has not applied this file; it crash-loops on boot. The
+-- deploy tool reads this tag out of the source tree it is about to deploy and refuses
+-- the deploy while the target DB's schema_migrations lacks this row, instead of the
+-- crash-loop being what surfaces the requirement.)
+--
-- (manual: the statement is a safe column WIDEN that preserves every existing value,
-- but `MODIFY ... NOT NULL` is indistinguishable from a narrowing to the auto-apply
-- destructive-DDL classifier, and widening past 85 chars forces a COPY table rebuild
diff --git a/src/sql/migrations/2026-08-10-action-data-utf8mb4.sql b/src/sql/migrations/2026-08-10-action-data-utf8mb4.sql
index 996dafc..4b927e2 100644
--- a/src/sql/migrations/2026-08-10-action-data-utf8mb4.sql
+++ b/src/sql/migrations/2026-08-10-action-data-utf8mb4.sql
@@ -12,7 +12,14 @@
--
--********************************************************************
--- xchain:migration mode=manual
+-- xchain:migration mode=manual deploy-precondition=required
+-- (deploy-precondition=required: src/db.js asserts both columns' charset at startup
+-- (_assertActionDataIsUtf8mb4), so code carrying that assertion cannot run against a
+-- database that has not applied this file; it crash-loops on boot. The deploy tool
+-- reads this tag out of the source tree it is about to deploy and refuses the deploy
+-- while the target DB's schema_migrations lacks this row, instead of the crash-loop
+-- being what surfaces the requirement.)
+--
-- (manual: a charset conversion re-encodes and rewrites every row of `transactions`,
-- which can be large. Never run that unattended at startup on a validator fleet.)
--
diff --git a/test/unit/migration-preconditions.test.js b/test/unit/migration-preconditions.test.js
new file mode 100644
index 0000000..e94d855
--- /dev/null
+++ b/test/unit/migration-preconditions.test.js
@@ -0,0 +1,283 @@
+'use strict';
+
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ * Deploy-precondition contract.
+ *
+ * A startup assertion that requires an operator-gated migration is a deploy
+ * precondition: build the code, ship it to a database that never applied the
+ * migration, and the service crash-loops on boot. A v0.10.0 fleet deploy put five
+ * of nine decoders into exactly that state, because none of the three migrations
+ * this tree asserts at startup carried a header the deploy tool could read.
+ *
+ * The fix has two halves that must agree: Database.STARTUP_ASSERTED_MIGRATIONS
+ * (what this code asserts) and the `deploy-precondition=required` header tag in
+ * each migration file (what the deploy tool can read out of a source tree it has
+ * only cloned). This suite is what keeps them in step. Mirrors the equivalent
+ * suite in xchain-indexer.
+ *
+ ********************************************************************/
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const Database = require('../../src/db');
+
+const MIG_DIR = path.join(__dirname, '..', '..', 'src', 'sql', 'migrations');
+
+const modeOf = Database.prototype._migrationMode.bind({});
+const readMigration = (file) => fs.readFileSync(path.join(MIG_DIR, file), 'utf8');
+const allMigrations = () => fs.readdirSync(MIG_DIR).filter(f => f.endsWith('.sql')).sort();
+
+describe('Database.migrationDeclaresDeployPrecondition @regression @tier1', function () {
+
+ it('reads the tag off the xchain:migration directive line', function () {
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition(
+ '-- xchain:migration mode=manual deploy-precondition=required\nALTER TABLE t MODIFY c VARCHAR(130);'), true);
+ });
+
+ it('tolerates spacing around the token', function () {
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition(
+ '-- xchain:migration mode = manual deploy-precondition = required\nALTER TABLE t;'), true);
+ });
+
+ it('is false for an ordinary tagged migration', function () {
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition(
+ '-- xchain:migration mode=manual\nALTER TABLE t;'), false);
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition(
+ '-- xchain:migration mode=auto\nALTER TABLE t;'), false);
+ });
+
+ it('is false for an untagged file and for empty input', function () {
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition('ALTER TABLE t;'), false);
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition(''), false);
+ });
+
+ it('ignores the token once the SQL body has started, so prose or a data literal cannot arm it', function () {
+ // Same prologue anchoring as _migrationMode: a comment AFTER the first statement
+ // is body text. Without this, a migration that merely discusses the convention
+ // would be read as declaring itself a precondition and block every deploy.
+ const raw = 'ALTER TABLE t;\n-- xchain:migration mode=manual deploy-precondition=required\n';
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition(raw), false);
+ });
+
+ it('ignores the token on a comment line that is not the xchain:migration directive', function () {
+ const raw = '-- deploy-precondition=required (prose about another file)\n-- xchain:migration mode=manual\nALTER TABLE t;';
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition(raw), false);
+ });
+
+ it('sees the tag through a long license banner (the prologue is unbounded)', function () {
+ const banner = Array(30).fill('-- license line').join('\n');
+ const raw = banner + '\n\n-- xchain:migration mode=manual deploy-precondition=required\nALTER TABLE t;';
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition(raw), true);
+ });
+});
+
+describe('Database.STARTUP_ASSERTED_MIGRATIONS @regression @tier1', function () {
+
+ it('registers exactly the three migrations this tree asserts at startup', function () {
+ const files = Database.STARTUP_ASSERTED_MIGRATIONS.map(m => m.file).sort();
+ assert.deepStrictEqual(files, [
+ '2026-06-13-dispensers-expiration-bigint.sql',
+ '2026-07-24-pubkeys-widen-uncompressed.sql',
+ '2026-08-10-action-data-utf8mb4.sql',
+ ].sort());
+ });
+
+ Database.STARTUP_ASSERTED_MIGRATIONS.forEach(function (entry) {
+
+ it(entry.file + ': the registered migration exists on disk', function () {
+ assert.ok(fs.existsSync(path.join(MIG_DIR, entry.file)),
+ entry.file + ' is registered as a startup-asserted migration but is not in ' + MIG_DIR +
+ '; the deploy guard would look for a row no file can ever produce.');
+ });
+
+ it(entry.file + ': carries the deploy-precondition header tag', function () {
+ assert.strictEqual(Database.migrationDeclaresDeployPrecondition(readMigration(entry.file)), true,
+ entry.file + ' is asserted at startup but does not declare `' + Database.DEPLOY_PRECONDITION_TAG +
+ '` in its header, so the deploy tool cannot see the requirement and the next fleet deploy ' +
+ 'discovers it as a crash-loop.');
+ });
+
+ it(entry.file + ': is mode=manual (an auto migration cannot be a missing precondition)', function () {
+ assert.strictEqual(modeOf(readMigration(entry.file)), 'manual',
+ entry.file + ' is tagged auto, so it applies itself at the first startup that sees it and ' +
+ 'has no business being a deploy precondition. Either the tag or the registration is wrong.');
+ });
+
+ it(entry.file + ': names a real assertion method on Database', function () {
+ assert.strictEqual(typeof Database.prototype[entry.assertion], 'function',
+ entry.assertion + ' is registered but is not a method on Database.prototype - the registry ' +
+ 'names an assertion this class does not define.');
+ });
+ });
+
+ it('every tagged migration file is registered (no tag without an assertion behind it)', function () {
+ const registered = new Set(Database.STARTUP_ASSERTED_MIGRATIONS.map(m => m.file));
+ const tagged = allMigrations().filter(f => Database.migrationDeclaresDeployPrecondition(readMigration(f)));
+ const orphans = tagged.filter(f => !registered.has(f));
+ assert.deepStrictEqual(orphans, [],
+ 'these files declare themselves deploy preconditions but no startup assertion is registered for ' +
+ 'them, so every deploy would be refused for a requirement this code does not actually have: ' +
+ orphans.join(', '));
+ });
+
+ it('no mode=auto migration carries the tag', function () {
+ const offenders = allMigrations().filter(f => {
+ const raw = readMigration(f);
+ return Database.migrationDeclaresDeployPrecondition(raw) && modeOf(raw) === 'auto';
+ });
+ assert.deepStrictEqual(offenders, [], 'auto migrations self-apply and can never be the missing ' +
+ 'precondition; tagging one makes the deploy guard refuse a deploy it should let through: ' + offenders.join(', '));
+ });
+
+ describe('startupAssertedMigrationFile()', function () {
+ it('resolves each registered assertion to its migration filename', function () {
+ assert.strictEqual(Database.startupAssertedMigrationFile('_assertDispenserExpirationIsBigintUnsigned'),
+ '2026-06-13-dispensers-expiration-bigint.sql');
+ assert.strictEqual(Database.startupAssertedMigrationFile('_assertPubkeyColumnIsUncompressedWide'),
+ '2026-07-24-pubkeys-widen-uncompressed.sql');
+ assert.strictEqual(Database.startupAssertedMigrationFile('_assertActionDataIsUtf8mb4'),
+ '2026-08-10-action-data-utf8mb4.sql');
+ });
+ it('throws on an unregistered assertion rather than yielding undefined', function () {
+ // "node src/migrate.js --file undefined" is worse than useless in the middle
+ // of an outage; the lookup must fail where the registry is wrong.
+ assert.throws(() => Database.startupAssertedMigrationFile('_assertSomethingNobodyRegistered'),
+ /STARTUP_ASSERTED_MIGRATIONS/);
+ });
+ });
+});
+
+describe('startup assertion error text names the registered file @regression @tier1', function () {
+
+ // Minimal fake connections: each assertion only reads its own information_schema
+ // rows, so a bare object with getConnection is enough to exercise the error text.
+ function ctxReturning(rows) {
+ return {
+ dbName: 'test_decoder',
+ transactionConnection: null,
+ getConnection: async () => ({
+ query: async () => rows,
+ release: async () => {}
+ })
+ };
+ }
+
+ it('_assertDispenserExpirationIsBigintUnsigned names the exact migration file', async function () {
+ let message = null;
+ try {
+ await Database.prototype._assertDispenserExpirationIsBigintUnsigned.call(
+ ctxReturning([{ dataType: 'datetime', columnType: 'datetime' }]));
+ } catch (err) {
+ message = err.message;
+ }
+ assert.ok(message, 'a DATETIME column must fail the assertion');
+ assert.ok(message.includes('--file 2026-06-13-dispensers-expiration-bigint.sql'),
+ 'the halt message must name the migration; got: ' + message);
+ });
+
+ it('_assertPubkeyColumnIsUncompressedWide names the exact migration file', async function () {
+ let message = null;
+ try {
+ await Database.prototype._assertPubkeyColumnIsUncompressedWide.call(ctxReturning([{ len: 66 }]));
+ } catch (err) {
+ message = err.message;
+ }
+ assert.ok(message, 'a 66-char column must fail the assertion');
+ assert.ok(message.includes('--file 2026-07-24-pubkeys-widen-uncompressed.sql'),
+ 'the halt message must name the migration; got: ' + message);
+ });
+
+ it('_assertActionDataIsUtf8mb4 names the exact migration file', async function () {
+ let message = null;
+ try {
+ await Database.prototype._assertActionDataIsUtf8mb4.call(
+ ctxReturning([{ tbl: 'transactions', cs: 'utf8mb3' }]));
+ } catch (err) {
+ message = err.message;
+ }
+ assert.ok(message, 'a utf8mb3 column must fail the assertion');
+ assert.ok(message.includes('--file 2026-08-10-action-data-utf8mb4.sql'),
+ 'the halt message must name the migration; got: ' + message);
+ });
+});
+
+describe('Database.MIGRATION_PRECONDITIONS: pubkeys widen predicate @regression', function () {
+
+ const skipWhen = Database.MIGRATION_PRECONDITIONS['2026-07-24-pubkeys-widen-uncompressed.sql'].skipWhen;
+
+ it('baselines when the column already holds an uncompressed key (130 chars)', function () {
+ const reason = skipWhen([{ len: 130 }]);
+ assert.ok(reason, 'expected a baseline reason string');
+ assert.match(reason, /already 130 characters wide/);
+ });
+
+ it('baselines when the column is wider than required', function () {
+ assert.ok(skipWhen([{ len: 191 }]));
+ });
+
+ it('does NOT baseline at the pre-migration shape (narrow VARCHAR(66))', function () {
+ assert.strictEqual(skipWhen([{ len: 66 }]), null);
+ });
+
+ it('does NOT baseline when the column is absent', function () {
+ assert.strictEqual(skipWhen([]), null);
+ });
+
+ it('does NOT baseline when the length is unreadable (NULL)', function () {
+ assert.strictEqual(skipWhen([{ len: null }]), null);
+ });
+});
+
+describe('Database.MIGRATION_PRECONDITIONS: action-data utf8mb4 predicate @regression', function () {
+
+ const skipWhen = Database.MIGRATION_PRECONDITIONS['2026-08-10-action-data-utf8mb4.sql'].skipWhen;
+
+ it('baselines when both columns already carry utf8mb4', function () {
+ const reason = skipWhen([
+ { tbl: 'transactions', cs: 'utf8mb4' },
+ { tbl: 'mempool_transactions', cs: 'utf8mb4' },
+ ]);
+ assert.ok(reason, 'expected a baseline reason string');
+ assert.match(reason, /already utf8mb4/);
+ });
+
+ it('does NOT baseline at the pre-migration shape (both still utf8mb3)', function () {
+ assert.strictEqual(skipWhen([
+ { tbl: 'transactions', cs: 'utf8mb3' },
+ { tbl: 'mempool_transactions', cs: 'utf8mb3' },
+ ]), null);
+ });
+
+ it('does NOT baseline a half-converted pair (one column still lagging)', function () {
+ assert.strictEqual(skipWhen([
+ { tbl: 'transactions', cs: 'utf8mb4' },
+ { tbl: 'mempool_transactions', cs: 'utf8mb3' },
+ ]), null);
+ });
+
+ it('does NOT baseline when either column is absent', function () {
+ assert.strictEqual(skipWhen([]), null);
+ assert.strictEqual(skipWhen([{ tbl: 'transactions', cs: 'utf8mb4' }]), null);
+ });
+
+ it('does NOT baseline when a charset is unreadable (NULL)', function () {
+ assert.strictEqual(skipWhen([
+ { tbl: 'transactions', cs: null },
+ { tbl: 'mempool_transactions', cs: 'utf8mb4' },
+ ]), null);
+ });
+});
From fcaa1d0376007fe9ab74df6df6edb58ca316e4dc Mon Sep 17 00:00:00 2001
From: J-Dog
Date: Mon, 24 Aug 2026 08:51:34 -0700
Subject: [PATCH 5/9] chore(coins): sync the fresh testnet genesis registry and
repin the height tests
---
src/coins/BTC.js | 13 +++++++------
src/coins/DOGE.js | 14 +++++++-------
src/coins/LTC.js | 13 +++++++------
src/coins/consensus_pin.js | 13 ++++++++++---
test/unit/CryptoNetworks.test.js | 12 ++++++------
5 files changed, 37 insertions(+), 28 deletions(-)
diff --git a/src/coins/BTC.js b/src/coins/BTC.js
index d2b5182..c69c5bd 100644
--- a/src/coins/BTC.js
+++ b/src/coins/BTC.js
@@ -166,12 +166,13 @@ module.exports = {
minStandardTxNonWitnessSize: 82,
singleOpReturnPolicy: true,
},
- // Fresh testnet genesis 2026-08-10 (operator): was 138000. Raised to
- // just under the live tip (147799 at the decision) so the chain starts
- // effectively empty and replays in seconds. Consensus input (folded
- // into consensusSubset), so it moves the BTC testnet pin and ships in
- // one wave with every other vendoring service.
- firstBlock: 147500,
+ // Fresh testnet genesis 2026-08-24 (operator): was 147500 (the
+ // 2026-08-10 genesis). Raised to just under the live tip (149703 at
+ // the decision) so the public testnet announces with zero
+ // pre-announcement test actions and replays in seconds. Consensus
+ // input (folded into consensusSubset), so it moves the BTC testnet
+ // pin and ships in one wave with every other vendoring service.
+ firstBlock: 149700,
// Block-0 hash of the chain (see mainnet above). Unpinned until the operator
// reads it off the fleet's own node: `bitcoin-cli -testnet getblockhash 0`.
// This is the one value that separates testnet3 from testnet4, which the
diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js
index d7c969b..424394c 100644
--- a/src/coins/DOGE.js
+++ b/src/coins/DOGE.js
@@ -111,13 +111,13 @@ module.exports = {
supportsSegwit: false,
singleOpReturnPolicy: true,
},
- // Fresh testnet genesis 2026-08-10 (operator): was 64800000, which is
- // ~3.02M blocks behind the tip and ~21 hours of replay - the single
- // reason this genesis is worth doing. Raised to just under the live
- // tip (67819590 at the decision) so replay is minutes. Consensus input
- // (folded into consensusSubset), so it moves the DOGE testnet pin and
- // ships in one wave with every other vendoring service.
- firstBlock: 67815000,
+ // Fresh testnet genesis 2026-08-24 (operator): was 67815000 (the
+ // 2026-08-10 genesis). Raised to just under the live tip (67847591 at
+ // the decision) so the public testnet announces with zero
+ // pre-announcement test actions and replays in minutes. Consensus
+ // input (folded into consensusSubset), so it moves the DOGE testnet
+ // pin and ships in one wave with every other vendoring service.
+ firstBlock: 67847500,
// Block-0 hash of the chain (see mainnet above). Unpinned until the operator
// reads it off the fleet's own node: `dogecoin-cli -testnet getblockhash 0`.
chainGenesisHash: null,
diff --git a/src/coins/LTC.js b/src/coins/LTC.js
index 1bc25bf..02a23fd 100644
--- a/src/coins/LTC.js
+++ b/src/coins/LTC.js
@@ -109,12 +109,13 @@ module.exports = {
minStandardTxNonWitnessSize: 85,
singleOpReturnPolicy: true,
},
- // Fresh testnet genesis 2026-08-10 (operator): was 4765000. Raised to
- // just under the live tip (4855452 at the decision) so the chain
- // starts effectively empty and replays in seconds. Consensus input
- // (folded into consensusSubset), so it moves the LTC testnet pin and
- // ships in one wave with every other vendoring service.
- firstBlock: 4855000,
+ // Fresh testnet genesis 2026-08-24 (operator): was 4855000 (the
+ // 2026-08-10 genesis). Raised to just under the live tip (4862567 at
+ // the decision) so the public testnet announces with zero
+ // pre-announcement test actions and replays in seconds. Consensus
+ // input (folded into consensusSubset), so it moves the LTC testnet
+ // pin and ships in one wave with every other vendoring service.
+ firstBlock: 4862500,
// Block-0 hash of the chain (see mainnet above). Unpinned until the operator
// reads it off the fleet's own node: `litecoin-cli -testnet getblockhash 0`.
chainGenesisHash: null,
diff --git a/src/coins/consensus_pin.js b/src/coins/consensus_pin.js
index f7ad12e..50ce2d0 100644
--- a/src/coins/consensus_pin.js
+++ b/src/coins/consensus_pin.js
@@ -59,10 +59,17 @@ module.exports = {
// all three testnet hashes move and the one-wave rule above applies in full.
// Regtest and mainnet are untouched, and their hashes were re-verified
// against the canonical files as unchanged by this edit.
+ //
+ // REGENERATED 2026-08-24 (fresh testnet genesis, operator): testnet
+ // `firstBlock` moved to just under the live tip on all three chains
+ // (BTC 147500 -> 149700, LTC 4855000 -> 4862500, DOGE 67815000 -> 67847500)
+ // so the public testnet announces with zero pre-announcement test actions.
+ // Same one-wave rule as every regeneration above. Regtest and mainnet are
+ // untouched and were re-verified as unchanged by this edit.
testnet: {
- BTC: '1e45a958ff9eb6a88be8684e3801b57e7afcfc9031f7761e4f4b1dcf1c8d42a9',
- LTC: '888818a874d6d8acb3363355089f0de601c355b63fc8431a44ef666f91615202',
- DOGE: 'ea3ee0d1407959f3cb59e4baf66b50dfc2ada9962351e578d7c6d8586e6ff905',
+ BTC: 'f6589c6b88dc930db05998070ef0b73743f58623a0d23fbc30fdb158c49d1427',
+ LTC: '9faf066a1470be2486d8a2cd121548ca02de1397d0678a2ab8dc0e712ebfa8fd',
+ DOGE: '2991d7e7caf2b212de959dd5831ac1477e0b13da95ac1ed8c2b43e2704732439',
},
regtest: {
diff --git a/test/unit/CryptoNetworks.test.js b/test/unit/CryptoNetworks.test.js
index 6a4ed51..c426f95 100644
--- a/test/unit/CryptoNetworks.test.js
+++ b/test/unit/CryptoNetworks.test.js
@@ -119,24 +119,24 @@ describe('CryptoNetworks', () => {
assert.strictEqual(CryptoNetworks.getFirstBlock('bitcoin-mainnet'), 950000)
})
- it('should return 147500 for bitcoin-testnet', () => {
- assert.strictEqual(CryptoNetworks.getFirstBlock('bitcoin-testnet'), 147500)
+ it('should return 149700 for bitcoin-testnet', () => {
+ assert.strictEqual(CryptoNetworks.getFirstBlock('bitcoin-testnet'), 149700)
})
it('[REGRESSION P2] R-NET-005: should return 3120000 for litecoin-mainnet', () => {
assert.strictEqual(CryptoNetworks.getFirstBlock('litecoin-mainnet'), 3120000)
})
- it('should return 4855000 for litecoin-testnet', () => {
- assert.strictEqual(CryptoNetworks.getFirstBlock('litecoin-testnet'), 4855000)
+ it('should return 4862500 for litecoin-testnet', () => {
+ assert.strictEqual(CryptoNetworks.getFirstBlock('litecoin-testnet'), 4862500)
})
it('[REGRESSION P2] R-NET-005: should return 6240000 for dogecoin-mainnet', () => {
assert.strictEqual(CryptoNetworks.getFirstBlock('dogecoin-mainnet'), 6240000)
})
- it('should return 67815000 for dogecoin-testnet', () => {
- assert.strictEqual(CryptoNetworks.getFirstBlock('dogecoin-testnet'), 67815000)
+ it('should return 67847500 for dogecoin-testnet', () => {
+ assert.strictEqual(CryptoNetworks.getFirstBlock('dogecoin-testnet'), 67847500)
})
it('[REGRESSION P2] R-NET-005: should return 0 for all regtest networks', () => {
From cb49cb1f5bd7c48c24ada84b6a7dbf05afc2722c Mon Sep 17 00:00:00 2001
From: J-Dog
Date: Mon, 24 Aug 2026 23:20:34 -0700
Subject: [PATCH 6/9] coins: update BTC mainnet reward pool address
---
src/coins/BTC.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/coins/BTC.js b/src/coins/BTC.js
index c69c5bd..dc3bcb7 100644
--- a/src/coins/BTC.js
+++ b/src/coins/BTC.js
@@ -122,7 +122,7 @@ module.exports = {
DONATE1: '1Donate1GERVKPW6GFQcnGeTa8dgL6Abyp', // Protocol Development
DONATE2: '1Donate2LkbBrsanwCVRPWZCXAqQcvcqGz', // Community Development
FEE_DESTINATION: '1FeesxM9LTEjBYVTkynK6jfDBgvksuh2WL', // native-fee destination (env-overridable)
- REWARD: '1rewardsZAyeuLeFJKoAepYiNN5N6uSzn', // validator reward pool (COLLECT)
+ REWARD: '1RewardsRQTXMAytLt4bBQvPEscKsSEXt', // validator reward pool (COLLECT)
EXPLORER: '1Donate3GBGSZzzrS9U9gUgURYKscAE6Yn', // display-only donation; not read by indexer
},
// Genesis ledger bootstrap pin (Counterparty name carry-forward).
From 5844422e54ce213139ba197d6b36439140832306 Mon Sep 17 00:00:00 2001
From: J-Dog
Date: Tue, 25 Aug 2026 07:31:37 -0700
Subject: [PATCH 7/9] fix(decoder): bind DISPENSER v0 field positions to the
pinned constants
The block-loop reads and the operator log line spelled wire field positions as
bare numeric literals beside the indexer-derived constants they must track.
---
src/BlockchainConnector.js | 53 +++++-
src/XChainDecoder.js | 42 +++--
src/oracleFeeOutput.js | 26 ++-
.../blockchainConnectorReviewFixes.test.js | 77 +++++++++
test/unit/chunkLaneCommitFetch.test.js | 152 ++++++++++++++++++
test/unit/dispenserFieldOffsets.test.js | 44 ++++-
test/unit/dispenserOracleFeeOutput.test.js | 6 +
7 files changed, 381 insertions(+), 19 deletions(-)
create mode 100644 test/unit/chunkLaneCommitFetch.test.js
diff --git a/src/BlockchainConnector.js b/src/BlockchainConnector.js
index feb3f97..5259ae6 100644
--- a/src/BlockchainConnector.js
+++ b/src/BlockchainConnector.js
@@ -19,7 +19,30 @@
********************************************************************/
const axios = require('axios');
-axios.defaults.timeout = parseInt(process.env.NODE_RPC_TIMEOUT ?? '30000', 10)
+
+// Read an integer env var, falling back on anything that is not a clean integer.
+// `??` only substitutes for null/undefined, so a present-but-empty value (a bare
+// `VAR=` line in a .env or compose file) reaches parseInt('') and yields NaN, and
+// a unit-suffixed one ('30s') truncates to a wrong magnitude. Both matter for the
+// RPC timeout below, which axios gates on `if (config.timeout)`: NaN is falsy, so
+// no timeout is installed at all and a black-holed node hangs forever instead of
+// raising ECONNABORTED, taking the whole timeout-retry and endpoint-failover
+// ladder with it. Warn on a discarded value so a mis-set env is visible in logs.
+function envInt(raw, fallback, name, min = 1) {
+ const s = (raw === undefined || raw === null) ? '' : String(raw).trim()
+ if (s === '') {
+ if (raw !== undefined && raw !== null) console.warn(`[config] ${name} is set but empty; using ${fallback}`)
+ return fallback
+ }
+ const n = /^-?\d+$/.test(s) ? Number(s) : NaN
+ if (!Number.isInteger(n) || n < min) {
+ console.warn(`[config] ${name}="${s}" is not an integer >= ${min}; using ${fallback}`)
+ return fallback
+ }
+ return n
+}
+
+axios.defaults.timeout = envInt(process.env.NODE_RPC_TIMEOUT, 30000, 'NODE_RPC_TIMEOUT')
// Sanitize an axios error before it is logged or re-thrown. Every RPC call passes
// `auth: { username: rpcUser, password: rpcPassword }`, and axios attaches the request
@@ -336,7 +359,9 @@ class BlockchainConnector {
// timing out precisely because it is overloaded. Matches getRawTransaction's
// sleep-based backoff. Env-tunable so tests can set it to 0.
async backoffOnTimeout() {
- const delay = parseInt(process.env.RPC_TIMEOUT_RETRY_DELAY_MS ?? '500', 10)
+ // min 0, not 1: the comment above documents 0 as a supported test setting
+ // (test/unit/setup.js relies on it), so it must survive the validation.
+ const delay = envInt(process.env.RPC_TIMEOUT_RETRY_DELAY_MS, 500, 'RPC_TIMEOUT_RETRY_DELAY_MS', 0)
if (delay > 0) await this.sleep(delay)
}
@@ -615,6 +640,26 @@ class BlockchainConnector {
const response = await this.rpcPost(data)
+ // A JSON-RPC 2.0 node (Bitcoin Core >= v28) answers an RPC error with
+ // HTTP 200 and a body error object, so axios never throws and the
+ // classifier below is never reached. Re-shape a coded error into the
+ // same error the HTTP-500 transport produces so both transports are
+ // classified at one point: -429 keeps its 5s backoff, -28 and auth
+ // faults keep their retries, and rpcErrors still counts them.
+ // -5 is the node's "tx absent" answer and stays the tolerant path
+ // below; an error object with no numeric code is not classifiable, so
+ // it keeps the pre-existing tolerant behaviour rather than gaining a
+ // new failure mode here.
+ const httpRpcError = response.data?.error
+ if (httpRpcError && typeof httpRpcError.code === 'number' && httpRpcError.code !== -5) {
+ // Build a fresh error each attempt and copy (never alias) the axios
+ // response: sanitizeRpcError scrubs error.response in place, so a
+ // shared object would carry the JSON body only on the first read.
+ const err = new Error(`getRawTransaction: RPC error ${httpRpcError.code}: ${httpRpcError.message}`)
+ err.response = { status: response.status, data: { error: { code: httpRpcError.code, message: httpRpcError.message } } }
+ throw err
+ }
+
// Return (not break) so
// a success on the final attempt cannot fall through to the failure
// guard below and inflate rpcErrors on a recovered fetch.
@@ -761,4 +806,6 @@ module.exports = BlockchainConnector
module.exports.encodeVarintHex = encodeVarintHex
// Exported for the cross-repo strip-parity test.
module.exports.stripAuxPowFromBlockHex = stripAuxPowFromBlockHex
-module.exports.skipAuxPow = skipAuxPow
\ No newline at end of file
+module.exports.skipAuxPow = skipAuxPow
+// Exported for the env-parsing regression test.
+module.exports.envInt = envInt
\ No newline at end of file
diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js
index 60985ed..4df6171 100644
--- a/src/XChainDecoder.js
+++ b/src/XChainDecoder.js
@@ -28,7 +28,7 @@ const ecc = require('tiny-secp256k1')
const BlockchainConnector = require('./BlockchainConnector')
const CryptoNetworks = require('./CryptoNetworks')
const XChainBlockDecoder = require('./XChainBlockDecoder')
-const { isOracleFeeCaptureActive, isOracleFeeSetCaptureActive, oracleAddressFromCreate, isCompactedOracleAddress, V0_EXPIRATION_INDEX, V2_EXPIRATION_INDEX } = require('./oracleFeeOutput')
+const { isOracleFeeCaptureActive, isOracleFeeSetCaptureActive, oracleAddressFromCreate, isCompactedOracleAddress, V0_GIVE_COIN_INDEX, V0_GET_COIN_INDEX, V0_GET_ADDRESS_INDEX, V0_REQUIRED_FIELD_COUNT, ORACLE_ADDRESS_INDEX, V0_EXPIRATION_INDEX, V2_EXPIRATION_INDEX } = require('./oracleFeeOutput')
const { isDispenserExpiryRealignActive } = require('./dispenserExpiryRealign')
const { captureCommands, collapseDispenserRegistrations, isBatchSubCommandCaptureActive } = require('./batchSubCommandCapture')
const { chainTierMismatch, chainFieldMissing, chainGenesisMismatch, chainGenesisUnpinned } = require('./chainIdentity')
@@ -707,7 +707,14 @@ class XChainDecoder {
return await this.parseTransaction(this.xchainBlockDecoder.transactionFromHex(rawTransaction))
}
- async getSourceFromOutput(txId, outputIndex){
+ // `capture`, when given, receives the parsed FIRST-HOP transaction for `txId` as
+ // `capture.sourceTransaction`. On the P2SH/P2WSH chunk lane that transaction is the
+ // same commit findFundingFeeOutputs would otherwise fetch a second time, so the
+ // caller can hand it over as prefetchedFundingTx. It is an out-parameter rather than
+ // a widened return value on purpose: the return contract (a source address or null)
+ // is stubbed and asserted across the suite, and a caller that ignores `capture`
+ // behaves exactly as before.
+ async getSourceFromOutput(txId, outputIndex, capture = null){
let source = null
let output = null
let outputTransaction = null
@@ -733,6 +740,11 @@ class XChainDecoder {
// LTC decoder instance permanently. transactionFromHex is the same parser the
// block path uses; for BTC/DOGE and non-flagged txs it is a plain parse.
outputTransaction = this.xchainBlockDecoder.transactionFromHex(outputRawTransaction)
+ // Publish the FIRST-HOP tx here, before the P2SH/P2WSH walk-back below can
+ // reassign `output`. The walk-back fetches the commit's own funder, a
+ // different transaction; handing that to the fee resolver would attribute
+ // another tx's outputs into this action's reserved FUNDING_VOUT_BASE domain.
+ if (capture) capture.sourceTransaction = outputTransaction
} catch (err){
this.rpcErrors++
console.error(`getSourceFromOutput: failed to fetch tx ${txId} (output ${outputIndex}): `, err)
@@ -996,7 +1008,8 @@ class XChainDecoder {
// prefetchedFundingTx: the Taproot-envelope path fetches the commit
// exactly once (spec §3.8) and hands the parsed tx in here, so the fee
// resolver extends to the commit without a second RPC round trip. The
- // chunk lanes keep the fetch below.
+ // P2SH/P2WSH chunk lanes hand in the commit getSourceFromOutput already
+ // parsed, so the fetch below is the fallback for a caller that has none.
let fundingTx = prefetchedFundingTx
if (!fundingTx){
try {
@@ -1073,7 +1086,7 @@ class XChainDecoder {
// create took a payment and dispensed nothing; the same create with an
// explicit EXPIRATION (15 tokens) dispensed correctly.
hasRequiredDispenserCreateFields(decodedDataSplit){
- return Array.isArray(decodedDataSplit) && decodedDataSplit.length >= 10
+ return Array.isArray(decodedDataSplit) && decodedDataSplit.length >= V0_REQUIRED_FIELD_COUNT
}
// The ORACLE_ADDRESSes whose native-coin outputs this transaction's payment-output
@@ -1134,7 +1147,7 @@ class XChainDecoder {
// (addressRefFields.js `noCompact`), so this is a third-party composer or
// a historical replay.
this.parseErrors++
- console.error(`Oracle-fee output NOT captured for tx ${transactionHash}: compacted ORACLE_ADDRESS reference '${fields[13]}' cannot be resolved by the decoder, so the indexer will reject this dispenser create`)
+ console.error(`Oracle-fee output NOT captured for tx ${transactionHash}: compacted ORACLE_ADDRESS reference '${fields[ORACLE_ADDRESS_INDEX]}' cannot be resolved by the decoder, so the indexer will reject this dispenser create`)
return []
}
let createOracleAddress = oracleAddressFromCreate(fields)
@@ -1628,10 +1641,11 @@ class XChainDecoder {
//Envelope reveals attribute differently (§3.4): ins[0]'s prevout is the
//one-time P2TR commit output, so the source is the address funding the
//COMMIT (its ins[0] prevout), resolved from the already-fetched commit.
+ let sourceCommitCapture = {}
if (getSource && (source == null)){
source = envelopeCarrier
? await this.getEnvelopeSourceFromCommit(envelopeCommitTransaction)
- : await this.getSourceFromOutput(firstInputTxId, transaction.ins[0].index)
+ : await this.getSourceFromOutput(firstInputTxId, transaction.ins[0].index, sourceCommitCapture)
}
//Extract and store public key from the first input if source was found
@@ -1648,7 +1662,15 @@ class XChainDecoder {
//For a P2SH/P2WSH reveal, attribute the native-coin fee output (which lives on the funding
//commit tx) to this action so the indexer can validate it (see findFundingFeeOutputs).
if (p2shFundingTxId){
- let fundingFeeOutputs = await this.findFundingFeeOutputs(p2shFundingTxId, envelopeCommitTransaction)
+ // The chunk lanes set p2shFundingTxId = firstInputTxId, which getSourceFromOutput
+ // above has already fetched and parsed, so reuse it instead of paying a second
+ // RPC round trip (with its own 10-attempt retry budget) for the same txid. The
+ // txid equality guard matters: getSourceFromOutput does not run when the source
+ // was already known or not needed, and findFundingFeeOutputs must still fetch
+ // for itself in that case.
+ let prefetchedFundingTx = envelopeCommitTransaction
+ || ((p2shFundingTxId === firstInputTxId && sourceCommitCapture.sourceTransaction) || null)
+ let fundingFeeOutputs = await this.findFundingFeeOutputs(p2shFundingTxId, prefetchedFundingTx)
for (let feeOutput of fundingFeeOutputs){
// Remap the FUNDING tx's vout into the reserved funding domain before this output
// is stored under the REVEAL's tx_index, so it can never collide on the
@@ -2932,9 +2954,9 @@ class XChainDecoder {
// ORACLE_ADDRESS; see hasRequiredDispenserCreateFields for
// the field map and for what the old >= 14 gate cost.
if (dispenserFormat === 0 && this.hasRequiredDispenserCreateFields(decodedDataSplit)){
- let giveCoin = decodedDataSplit[2]
- let getCoin = decodedDataSplit[7]
- let getAddress = decodedDataSplit[10]
+ let giveCoin = decodedDataSplit[V0_GIVE_COIN_INDEX]
+ let getCoin = decodedDataSplit[V0_GET_COIN_INDEX]
+ let getAddress = decodedDataSplit[V0_GET_ADDRESS_INDEX]
// Treat a missing token OR an empty-string token as an
// omitted EXPIRATION and substitute the same default the
diff --git a/src/oracleFeeOutput.js b/src/oracleFeeOutput.js
index 38e4d39..6ce81c1 100644
--- a/src/oracleFeeOutput.js
+++ b/src/oracleFeeOutput.js
@@ -41,10 +41,30 @@ const { ORACLE_FEE_OUTPUT_ACTIVATION, ORACLE_FEE_SET_CAPTURE_ACTIVATION } = requ
// Decoder offset = indexer format position + 1, because the decoder splits with the
// ACTION token ('DISPENSER') at 0 while the indexer's format string starts at VERSION.
// The comment is no longer the only contract: test/unit/dispenserFieldOffsets.test.js
-// derives all three offsets from the live sibling Dispenser's this.formats.
+// derives every one of these offsets from the live sibling Dispenser's this.formats.
+//
+// EVERY v0 position the decoder reads is named here, not just the ones oracle-fee
+// capture needs. GET_ADDRESS is the operating address the dispenser row is
+// registered under (dispensers.address_id), and that address set is what decides
+// which native-coin outputs reach transaction_outputs at all - the only outputs the
+// indexer ever sees. A field inserted ahead of it in the indexer's v0 format would
+// register the dispenser on the wrong token, capture nothing, and emit no DISPENSE
+// while the indexer kept the dispenser open and escrowed: the money-bearing
+// under-capture direction, and silent while every literal read still matched itself.
+const V0_GIVE_COIN_INDEX = 2
+const V0_GET_COIN_INDEX = 7
+const V0_GET_ADDRESS_INDEX = 10
const ORACLE_ADDRESS_INDEX = 13
const V0_EXPIRATION_INDEX = 14
+// Length of the REQUIRED run of a v0 create. Everything from GET_ADDRESS on is
+// optional (GET_ADDRESS defaults to SOURCE, EXPIRATION to a block-time window), so
+// the run ends at GET_AMOUNT (offset 9) and a conforming create is at least 10
+// tokens. It equals V0_GET_ADDRESS_INDEX only because the optional tail starts
+// exactly there; it is a COUNT, not a position, and the offsets test derives it
+// from GET_AMOUNT rather than from that coincidence.
+const V0_REQUIRED_FIELD_COUNT = 10
+
// Field positions in the DISPENSER v2 (edit) wire format, same +1 convention
// (indexer this.formats[2]):
// 0 DISPENSER | 1 VERSION | 2 DISPENSER_ACTION_INDEX | 3 GIVE_ESCROW
@@ -118,6 +138,10 @@ function isCompactedOracleAddress(fields){
}
module.exports = {
+ V0_GIVE_COIN_INDEX,
+ V0_GET_COIN_INDEX,
+ V0_GET_ADDRESS_INDEX,
+ V0_REQUIRED_FIELD_COUNT,
ORACLE_ADDRESS_INDEX,
V0_EXPIRATION_INDEX,
V2_EXPIRATION_INDEX,
diff --git a/test/unit/blockchainConnectorReviewFixes.test.js b/test/unit/blockchainConnectorReviewFixes.test.js
index 610517e..502e973 100644
--- a/test/unit/blockchainConnectorReviewFixes.test.js
+++ b/test/unit/blockchainConnectorReviewFixes.test.js
@@ -99,6 +99,42 @@ describe('BlockchainConnector RPC error accounting and reporting', () => {
}).timeout(5000)
})
+ describe('#getRawTransaction() classifies an HTTP-200 JSON-RPC error body', () => {
+ // A node honouring the jsonrpc:"2.0" request field (Bitcoin Core >= v28)
+ // returns RPC errors with HTTP 200, so axios never throws and the retry /
+ // backoff / accounting classifier below the success path is never reached.
+ it('retries a -429 queue-full error with the 5s backoff and counts it once', async () => {
+ const delays = []
+ connector.sleep = async (ms) => { delays.push(ms) }
+ axiosStub.resolves({ status: 200, data: { result: null, error: { code: -429, message: 'Work queue depth exceeded' } } })
+
+ await assert.rejects(
+ () => connector.getRawTransaction('txid'),
+ (err) => /failed after 10 attempts/.test(err.message) && /-429/.test(err.message)
+ )
+ assert.strictEqual(axiosStub.callCount, 10, 'the 10-attempt loop must run')
+ assert.strictEqual(connector.rpcErrors, 1, 'rpc_errors_total must see the failure')
+ assert.deepStrictEqual([...new Set(delays)], [5000], 'every backoff is the queue-full 5s one')
+ }).timeout(5000)
+
+ it('retries a transient -28 and resolves the tx once the node is ready', async () => {
+ axiosStub.onCall(0).resolves({ status: 200, data: { result: null, error: { code: -28, message: 'Loading block index' } } })
+ axiosStub.onCall(1).resolves({ status: 200, data: { result: 'deadbeef' } })
+
+ assert.strictEqual(await connector.getRawTransaction('txid'), 'deadbeef')
+ assert.strictEqual(axiosStub.callCount, 2)
+ assert.strictEqual(connector.rpcErrors, 0)
+ }).timeout(5000)
+
+ it('still resolves null on the first attempt for a -5 eviction', async () => {
+ axiosStub.resolves({ status: 200, data: { result: null, error: { code: -5, message: 'No such mempool or blockchain transaction' } } })
+
+ assert.strictEqual(await connector.getRawTransaction('txid'), null)
+ assert.strictEqual(axiosStub.callCount, 1, 'eviction tolerance must not burn retries')
+ assert.strictEqual(connector.rpcErrors, 0)
+ }).timeout(5000)
+ })
+
describe('block-path methods surface the HTTP-500 JSON-RPC error code', () => {
it('getBlockHash rethrows an error carrying the node rpcCode/rpcMessage', async () => {
const rpcErr = Object.assign(new Error('Request failed with status code 500'), {
@@ -149,4 +185,45 @@ describe('BlockchainConnector RPC error accounting and reporting', () => {
await assert.rejects(() => connector.getBlockHash(0), /RPC error -8: Block height out of range/)
}).timeout(5000)
})
+
+ describe('envInt() falls back on values that used to parse to NaN', () => {
+ // NODE_RPC_TIMEOUT feeds axios.defaults.timeout, which axios gates on
+ // `if (config.timeout)`. A NaN there installs NO timeout, so a node that
+ // accepts the connection and never answers hangs forever and the whole
+ // ECONNABORTED retry / endpoint-failover ladder is unreachable.
+ const { envInt } = BlockchainConnector
+ let warnStub
+
+ beforeEach(() => { warnStub = sinon.stub(console, 'warn') })
+
+ it('uses the default when the variable is unset', () => {
+ assert.strictEqual(envInt(undefined, 30000, 'NODE_RPC_TIMEOUT'), 30000)
+ assert.strictEqual(warnStub.callCount, 0, 'an unset variable is normal, not a misconfiguration')
+ })
+
+ it('uses the default (and warns) when the variable is present but empty', () => {
+ assert.strictEqual(envInt('', 30000, 'NODE_RPC_TIMEOUT'), 30000)
+ assert.strictEqual(envInt(' ', 30000, 'NODE_RPC_TIMEOUT'), 30000)
+ assert.strictEqual(warnStub.callCount, 2)
+ })
+
+ it('rejects a unit-suffixed value instead of truncating it to 30ms', () => {
+ assert.strictEqual(envInt('30s', 30000, 'NODE_RPC_TIMEOUT'), 30000)
+ assert.strictEqual(envInt('1e4', 30000, 'NODE_RPC_TIMEOUT'), 30000)
+ })
+
+ it('rejects zero and negatives at the default minimum of 1', () => {
+ assert.strictEqual(envInt('0', 30000, 'NODE_RPC_TIMEOUT'), 30000)
+ assert.strictEqual(envInt('-5', 30000, 'NODE_RPC_TIMEOUT'), 30000)
+ })
+
+ it('keeps 0 usable where the call site documents it (the retry backoff)', () => {
+ assert.strictEqual(envInt('0', 500, 'RPC_TIMEOUT_RETRY_DELAY_MS', 0), 0)
+ })
+
+ it('passes a valid value through unchanged and silently', () => {
+ assert.strictEqual(envInt('45000', 30000, 'NODE_RPC_TIMEOUT'), 45000)
+ assert.strictEqual(warnStub.callCount, 0)
+ })
+ })
})
diff --git a/test/unit/chunkLaneCommitFetch.test.js b/test/unit/chunkLaneCommitFetch.test.js
new file mode 100644
index 0000000..24d2a48
--- /dev/null
+++ b/test/unit/chunkLaneCommitFetch.test.js
@@ -0,0 +1,152 @@
+/*********************************************************************
+ *
+ * Copyright © 2025–2026 Dankest, LLC
+ * Based on XChain Platform by Dankest, LLC – https://dankest.llc
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This file is part of XChain Platform. Licensed under the GNU Affero
+ * General Public License v3.0 or later; see LICENSE.md. A commercial
+ * license (without AGPL source-disclosure terms) is available -
+ * contact legal@dankest.llc.
+ *
+ **********************************************************************
+ * Regression coverage for the P2SH/P2WSH chunk-carrier reveal lane: the commit
+ * transaction must be fetched exactly ONCE per parseTransaction call.
+ *
+ * getSourceFromOutput fetches and parses the commit (ins[0]'s prevout) for source
+ * attribution, and findFundingFeeOutputs then needs the same commit to attribute the
+ * native-coin fee output. The Taproot-envelope lane already hands the parsed commit
+ * over as prefetchedFundingTx; the chunk lanes used to re-fetch it through an uncached
+ * connector carrying its own 10-attempt retry budget.
+ */
+
+'use strict'
+
+const assert = require('assert')
+const sinon = require('sinon')
+const bitcoin = require('bitcoinjs-lib')
+const XChainDecoder = require('../../src/XChainDecoder')
+
+const FEE_ADDR = 'mzBc4XEFSdzCDcTxAgf6EZXgsZWpztRhef'
+const SOURCE_ADDR = 'mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt'
+const FEE_AMOUNT = 4321
+
+function createDecoder(){
+ const decoder = new XChainDecoder(
+ 'bitcoin-regtest', null, null, null, null, null,
+ '127.0.0.1', 18443, 'rpc', 'rpc', false
+ )
+ decoder.db = {
+ isThereADispenserForAddress: sinon.stub().resolves(false),
+ getAddressId: sinon.stub().resolves(null),
+ hasPubkey: sinon.stub().resolves(true),
+ insertPubkey: sinon.stub().resolves()
+ }
+ decoder.feeDestination = FEE_ADDR
+ return decoder
+}
+
+// Serve exactly the given transactions by txid; any other lookup rejects loudly.
+function wireConnector(decoder, txs){
+ const byId = {}
+ for (const t of txs) byId[t.getId()] = t.toHex()
+ const stub = sinon.stub().callsFake(async (txid) => {
+ if (byId[txid]) return byId[txid]
+ throw new Error('unit test: unexpected getRawTransaction for ' + txid)
+ })
+ decoder.connector = { getRawTransaction: stub }
+ return stub
+}
+
+function addSignatureLikeInput(tx, hash, index){
+ tx.addInput(hash, index)
+ tx.ins[tx.ins.length - 1].script = bitcoin.script.compile([Buffer.alloc(72, 0x30), Buffer.alloc(33, 0x02)])
+}
+
+describe('P2SH/P2WSH chunk-carrier reveal: one commit fetch per parse', function () {
+ let decoder, rpc, funderTx, commitTx, revealTx
+
+ beforeEach(() => {
+ decoder = createDecoder()
+
+ // The commit's own funder. Its vout 0 carries the address the reveal's source
+ // resolves to, via getSourceFromOutput's P2SH walk-back.
+ funderTx = new bitcoin.Transaction()
+ funderTx.version = 2
+ addSignatureLikeInput(funderTx, Buffer.alloc(32, 0x11), 0)
+ funderTx.addOutput(bitcoin.address.toOutputScript(SOURCE_ADDR, decoder.network), 100000)
+
+ // The commit: vout 0 is the P2SH script output the reveal spends, vout 1 is the
+ // native-coin fee output findFundingFeeOutputs must attribute to the action.
+ commitTx = new bitcoin.Transaction()
+ commitTx.version = 2
+ addSignatureLikeInput(commitTx, funderTx.getHash(), 0)
+ commitTx.addOutput(Buffer.from('a914' + 'bb'.repeat(20) + '87', 'hex'), 90000)
+ commitTx.addOutput(bitcoin.address.toOutputScript(FEE_ADDR, decoder.network), FEE_AMOUNT)
+
+ // The reveal: spends the commit's P2SH output, pays one ordinary output and
+ // carries the OP_RETURN that flags the chunk encoding.
+ revealTx = new bitcoin.Transaction()
+ revealTx.version = 2
+ addSignatureLikeInput(revealTx, commitTx.getHash(), 0)
+ revealTx.addOutput(bitcoin.address.toOutputScript(SOURCE_ADDR, decoder.network), 50000)
+ revealTx.addOutput(bitcoin.script.compile([bitcoin.opcodes.OP_RETURN, Buffer.alloc(20, 0x01)]), 0)
+
+ // Drive the P2SH chunk branch (sets p2shFundingTxId = firstInputTxId) without
+ // reproducing the obfuscation, exactly as parseTransaction.test.js does.
+ sinon.stub(decoder, 'removeObfuscation').resolves(
+ Buffer.concat([Buffer.from('XCHN'), Buffer.from('p2sh')])
+ )
+
+ rpc = wireConnector(decoder, [funderTx, commitTx])
+ })
+
+ afterEach(() => sinon.restore())
+
+ function dispenserSetFor(tx){
+ const set = new Set()
+ for (const out of tx.outs){
+ try { set.add(bitcoin.address.fromOutputScript(out.script, decoder.network)) } catch (err) { /* OP_RETURN */ }
+ }
+ return set
+ }
+
+ it('fetches the commit exactly once and still attributes its fee output', async function () {
+ const result = await decoder.parseTransaction(revealTx, dispenserSetFor(revealTx))
+ assert.ok(result, 'the reveal must parse')
+
+ // Source resolution walked back through the commit to its funder.
+ assert.strictEqual(result.source, SOURCE_ADDR)
+
+ // The decisive assertion: the commit txid is requested once, not twice.
+ const commitId = commitTx.getId()
+ const askedForCommit = rpc.args.filter(a => a[0] === commitId).length
+ assert.strictEqual(askedForCommit, 1,
+ `the commit must be fetched once per parse, saw ${askedForCommit}`)
+
+ // Two round trips total: the commit, and the commit's funder for attribution.
+ assert.strictEqual(rpc.callCount, 2)
+ assert.deepStrictEqual(
+ rpc.args.map(a => a[0]).sort(),
+ [commitId, funderTx.getId()].sort()
+ )
+
+ // The fee output is still found, and still remapped into the reserved domain.
+ const fees = result.paymentOutputs.filter(o => o.destinationAddress === FEE_ADDR)
+ assert.strictEqual(fees.length, 1)
+ assert.strictEqual(Number(fees[0].vout), XChainDecoder.FUNDING_VOUT_BASE + 1)
+ assert.strictEqual(Number(fees[0].amount), FEE_AMOUNT)
+ })
+
+ it('still fetches the commit itself when source resolution never ran', async function () {
+ // getSourceFromOutput is skipped when the source is already known, so the
+ // prefetch is absent and findFundingFeeOutputs must fall back to its own fetch.
+ // This is the guard the txid-equality check and null fallback exist for.
+ const outputs = await decoder.findFundingFeeOutputs(commitTx.getId())
+ assert.strictEqual(rpc.callCount, 1)
+ assert.strictEqual(outputs.length, 1)
+ assert.strictEqual(Number(outputs[0].vout), 1)
+ assert.strictEqual(Number(outputs[0].amount), FEE_AMOUNT)
+ })
+})
diff --git a/test/unit/dispenserFieldOffsets.test.js b/test/unit/dispenserFieldOffsets.test.js
index de2ba46..a36b7a9 100644
--- a/test/unit/dispenserFieldOffsets.test.js
+++ b/test/unit/dispenserFieldOffsets.test.js
@@ -12,8 +12,10 @@
// DISPENSER wire field-offset drift guard.
//
-// The decoder reads three DISPENSER fields by split offset: ORACLE_ADDRESS (the token
-// oracle-fee capture keys on), the v0 create EXPIRATION and the v2 edit EXPIRATION. The
+// The decoder reads six DISPENSER positions by split offset, plus the length of the
+// required run: GIVE_COIN and GET_COIN (the chain gate), GET_ADDRESS (the operating
+// address the dispenser row is registered under), ORACLE_ADDRESS (the token oracle-fee
+// capture keys on), the v0 create EXPIRATION and the v2 edit EXPIRATION. The
// authoritative layout is the indexer's own format strings
// (xchain-indexer/src/actions/dispenser.js this.formats), and until this guard existed the
// only thing binding the two was a prose comment, while every comparable dependency at this
@@ -23,7 +25,12 @@
// Drift is money-bearing in both directions: a field inserted ahead of ORACLE_ADDRESS makes
// capture key on the wrong token, so the indexer rejects every fee-bearing Mode B create
// with 'missing oracle fee output' after the payer's coin is spent; a shifted EXPIRATION
-// diverges the decoder's open-dispenser set from the indexer's.
+// diverges the decoder's open-dispenser set from the indexer's; and a shifted GET_ADDRESS
+// registers the dispenser on a token no output can pay, so its payments never reach
+// transaction_outputs and no DISPENSE is emitted while the indexer keeps it open and
+// escrowed. The guard covered only the last three of those until every position the
+// decode path reads was named - the three unbound ones happened to be correct, which is
+// what a coverage gap looks like from inside.
//
// Two tiers, so a one-sided edit fails somewhere no matter which checkout is present:
// 1. PIN - the constants equal the offsets this repo's decode path was written
@@ -41,13 +48,19 @@ const assert = require('assert');
const fs = require('fs');
const path = require('path');
-const { ORACLE_ADDRESS_INDEX, V0_EXPIRATION_INDEX, V2_EXPIRATION_INDEX, oracleAddressFromCreate } =
+const { V0_GIVE_COIN_INDEX, V0_GET_COIN_INDEX, V0_GET_ADDRESS_INDEX, V0_REQUIRED_FIELD_COUNT,
+ ORACLE_ADDRESS_INDEX, V0_EXPIRATION_INDEX, V2_EXPIRATION_INDEX, oracleAddressFromCreate } =
require('../../src/oracleFeeOutput.js');
// Offsets the decode path in src/XChainDecoder.js and src/oracleFeeOutput.js was written
// against. Decoder offset = indexer format position + 1: the decoder splits with the ACTION
// token ('DISPENSER') at 0, the indexer's format string starts at VERSION.
-const PINNED = { ORACLE_ADDRESS: 13, V0_EXPIRATION: 14, V2_EXPIRATION: 4 };
+// REQUIRED_FIELD_COUNT is a COUNT, not a position: the required run ends at GET_AMOUNT
+// (offset 9), so a conforming create is at least 10 tokens.
+const PINNED = {
+ GIVE_COIN: 2, GET_COIN: 7, GET_ADDRESS: 10, ORACLE_ADDRESS: 13,
+ V0_EXPIRATION: 14, V2_EXPIRATION: 4, REQUIRED_FIELD_COUNT: 10,
+};
const ACTION_TOKEN_OFFSET = 1;
const INDEXER_DISPENSER = process.env.XCHAIN_INDEXER_DIR
@@ -91,6 +104,10 @@ function offsetOf(format, field){
describe('DISPENSER wire field offsets', function () {
it('pins the offsets the decode path was written against', function () {
+ assert.strictEqual(V0_GIVE_COIN_INDEX, PINNED.GIVE_COIN);
+ assert.strictEqual(V0_GET_COIN_INDEX, PINNED.GET_COIN);
+ assert.strictEqual(V0_GET_ADDRESS_INDEX, PINNED.GET_ADDRESS);
+ assert.strictEqual(V0_REQUIRED_FIELD_COUNT, PINNED.REQUIRED_FIELD_COUNT);
assert.strictEqual(ORACLE_ADDRESS_INDEX, PINNED.ORACLE_ADDRESS);
assert.strictEqual(V0_EXPIRATION_INDEX, PINNED.V0_EXPIRATION);
assert.strictEqual(V2_EXPIRATION_INDEX, PINNED.V2_EXPIRATION);
@@ -116,6 +133,23 @@ describe('DISPENSER wire field offsets', function () {
assert.ok(formats && typeof formats === 'object',
'xchain-indexer Dispenser must expose this.formats');
+ assert.strictEqual(offsetOf(formats[0], 'GIVE_COIN'), V0_GIVE_COIN_INDEX,
+ 'GIVE_COIN moved in the indexer v0 format: the decoder would gate dispenser opens on '
+ + 'the wrong token, so a create for this chain would be skipped or one for another '
+ + 'chain registered');
+ assert.strictEqual(offsetOf(formats[0], 'GET_COIN'), V0_GET_COIN_INDEX,
+ 'GET_COIN moved in the indexer v0 format: same chain gate, same divergence between '
+ + 'the decoder open set and the indexer');
+ assert.strictEqual(offsetOf(formats[0], 'GET_ADDRESS'), V0_GET_ADDRESS_INDEX,
+ 'GET_ADDRESS moved in the indexer v0 format: the dispenser would be registered under '
+ + 'the wrong operating address, its native-coin payments would never be captured to '
+ + 'transaction_outputs, and no DISPENSE would be emitted while the indexer keeps the '
+ + 'dispenser open and escrowed');
+ // Derived from GET_AMOUNT, the last REQUIRED field, not from GET_ADDRESS: the two
+ // are equal today only because the optional tail starts exactly there.
+ assert.strictEqual(V0_REQUIRED_FIELD_COUNT, offsetOf(formats[0], 'GET_AMOUNT') + 1,
+ 'the required run no longer ends at GET_AMOUNT: the length gate would admit an '
+ + 'incomplete create or drop a conforming one, the shape the old >= 14 gate cost');
assert.strictEqual(offsetOf(formats[0], 'ORACLE_ADDRESS'), ORACLE_ADDRESS_INDEX,
'ORACLE_ADDRESS moved in the indexer v0 format: oracle-fee capture would key on the '
+ 'wrong token and the indexer would reject every fee-bearing Mode B create');
diff --git a/test/unit/dispenserOracleFeeOutput.test.js b/test/unit/dispenserOracleFeeOutput.test.js
index ed54a2b..86d4049 100644
--- a/test/unit/dispenserOracleFeeOutput.test.js
+++ b/test/unit/dispenserOracleFeeOutput.test.js
@@ -338,6 +338,12 @@ describe('DISPENSER PRICE v1 oracle-fee output capture', function () {
assert.strictEqual(decoder.captured.length, 0)
assert.ok(errors.some(e => e.includes('compacted ORACLE_ADDRESS')),
'the unresolvable reference is surfaced, not silently dropped')
+ // The message must quote the token from the SAME slot the capture decision read,
+ // or the field-position-drift investigation this line exists to serve is handed a
+ // neighbouring field. '^57' sits at ORACLE_ADDRESS_INDEX; its neighbours in this
+ // fixture are '' (FIAT_AMOUNT) and the expiration, so a slot slip shows up here.
+ assert.ok(errors.some(e => e.includes("reference '^57'")),
+ 'the log quotes the ORACLE_ADDRESS slot itself, not a neighbouring field')
assert.strictEqual(model.rows[0].oracleAddress, null,
'and no junk ^ token is stored on the dispenser row')
})
From ee0fe19edefccd53f9b10bc9bf39ba26df9f8348 Mon Sep 17 00:00:00 2001
From: J-Dog
Date: Tue, 25 Aug 2026 09:37:47 -0700
Subject: [PATCH 8/9] fix(decoder): vendored coin and observability
corrections, roundtrip fixture
The fee-destination comment in the vendored coin bundles said the override
was env-overridable after it had been restricted to regtest. Those three files
are vendored byte-identically across the fleet behind conformance guards, so the
correction is propagated rather than applied in one place.
Cumulative log-shipper totals were registered as gauges, so a _total-suffixed
series read to a scraper as a resettable level and rate() over it was undefined.
They are counters now, written through the monotonic setter that refuses a
backwards step; buffer depth is a real level and stays a gauge. Vendored through
the observability sync script, not hand-edited.
The shared roundtrip conformance fixture pinned every carrier lane except the
taproot envelope, which was the newest and the only unpinned one.
---
src/coins/BTC.js | 2 +-
src/coins/DOGE.js | 2 +-
src/coins/LTC.js | 2 +-
src/observability/logShipper.js | 17 ++--
test/fixtures/roundtrip-conformance.json | 84 ++++++++++++++++-
test/unit/compiledPushSizeConformance.test.js | 55 ++++++++++++
test/unit/roundtripConformance.test.js | 90 +++++++++++++++++--
7 files changed, 235 insertions(+), 17 deletions(-)
diff --git a/src/coins/BTC.js b/src/coins/BTC.js
index dc3bcb7..f36c86f 100644
--- a/src/coins/BTC.js
+++ b/src/coins/BTC.js
@@ -121,7 +121,7 @@ module.exports = {
GAS: '1XChain3M4uRwcHqt4XuhVBUQ8cL4qQsA',
DONATE1: '1Donate1GERVKPW6GFQcnGeTa8dgL6Abyp', // Protocol Development
DONATE2: '1Donate2LkbBrsanwCVRPWZCXAqQcvcqGz', // Community Development
- FEE_DESTINATION: '1FeesxM9LTEjBYVTkynK6jfDBgvksuh2WL', // native-fee destination (env-overridable)
+ FEE_DESTINATION: '1FeesxM9LTEjBYVTkynK6jfDBgvksuh2WL', // native-fee destination (regtest-only env override; ignored on mainnet/testnet)
REWARD: '1RewardsRQTXMAytLt4bBQvPEscKsSEXt', // validator reward pool (COLLECT)
EXPLORER: '1Donate3GBGSZzzrS9U9gUgURYKscAE6Yn', // display-only donation; not read by indexer
},
diff --git a/src/coins/DOGE.js b/src/coins/DOGE.js
index 424394c..71bd5d5 100644
--- a/src/coins/DOGE.js
+++ b/src/coins/DOGE.js
@@ -72,7 +72,7 @@ module.exports = {
GAS: 'DGasfpttCnTijuuoAdiJ9sXJjG7vQ5pMkW',
DONATE1: 'DDonate1RBcwGnCRNnVtwuCmQyWW1Gn25f', // Protocol Development
DONATE2: 'DDonate2o3Sg4phybp92oFpkmv8S9ZhGSV', // Community Development
- FEE_DESTINATION: 'DFeesjvoMoVqd9UDuwDSAxzHMF5xZFgeG9', // native-fee destination (env-overridable)
+ FEE_DESTINATION: 'DFeesjvoMoVqd9UDuwDSAxzHMF5xZFgeG9', // native-fee destination (regtest-only env override; ignored on mainnet/testnet)
REWARD: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', // structural only; COLLECT/XCHAIN are BTC-only
EXPLORER: 'DDonate3FCoUgi1bxW5r9c2p75uKTLw9qE', // display-only donation
},
diff --git a/src/coins/LTC.js b/src/coins/LTC.js
index 02a23fd..f02d1e3 100644
--- a/src/coins/LTC.js
+++ b/src/coins/LTC.js
@@ -72,7 +72,7 @@ module.exports = {
GAS: 'LXChainCN6yjHVqqS9tYzYVYZ8CCZcSx72',
DONATE1: 'Ldonate18tNZcVThKm5MX33EjvhaanJ6Mg', // Protocol Development
DONATE2: 'Ldonate2io846q2e7q8dUArh3TNnaq9ENb', // Community Development
- FEE_DESTINATION: 'Lfees7tszAx5Gqam2fuqf6biaX3LXafM4H', // native-fee destination (env-overridable)
+ FEE_DESTINATION: 'Lfees7tszAx5Gqam2fuqf6biaX3LXafM4H', // native-fee destination (regtest-only env override; ignored on mainnet/testnet)
REWARD: 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', // structural only; COLLECT/XCHAIN are BTC-only
EXPLORER: 'Ldonate3FfyqbYQAYxo3qjFLcu28oUdAfn', // display-only donation
},
diff --git a/src/observability/logShipper.js b/src/observability/logShipper.js
index 7b9d521..c76de8c 100644
--- a/src/observability/logShipper.js
+++ b/src/observability/logShipper.js
@@ -141,15 +141,20 @@ class LogShipper {
_attachMetrics(registry) {
const emitted = registry.counter({ name: 'log_lines_emitted_total', help: 'Log lines emitted by the structured log shim', labelNames: ['level'] });
- const shipped = registry.gauge({ name: 'log_lines_shipped_total', help: 'Log lines successfully shipped to the collector' });
- const dropped = registry.gauge({ name: 'log_lines_dropped_total', help: 'Log lines dropped because the ship buffer was full' });
- const failed = registry.gauge({ name: 'log_ship_failures_total', help: 'Failed log-ship batch attempts' });
+ // Cumulative totals are counters, not gauges: a _total-suffixed gauge
+ // reads to a scraper as a resettable level, so rate() over it is
+ // undefined. setMonotonic publishes the running total and refuses a
+ // backwards step, which is what a restarted source would otherwise do.
+ const shipped = registry.counter({ name: 'log_lines_shipped_total', help: 'Log lines successfully shipped to the collector' });
+ const dropped = registry.counter({ name: 'log_lines_dropped_total', help: 'Log lines dropped because the ship buffer was full' });
+ const failed = registry.counter({ name: 'log_ship_failures_total', help: 'Failed log-ship batch attempts' });
+ // Buffer depth IS a level, so it stays a gauge.
const pending = registry.gauge({ name: 'log_ship_buffer_lines', help: 'Log lines currently buffered for shipping' });
this._levelCounter = emitted;
registry.addCollector(() => {
- shipped.set({}, this.stats.shipped);
- dropped.set({}, this.stats.dropped);
- failed.set({}, this.stats.failures);
+ shipped.setMonotonic({}, this.stats.shipped);
+ dropped.setMonotonic({}, this.stats.dropped);
+ failed.setMonotonic({}, this.stats.failures);
pending.set({}, this.buffer.length);
});
}
diff --git a/test/fixtures/roundtrip-conformance.json b/test/fixtures/roundtrip-conformance.json
index 86e1e0b..0330b24 100644
--- a/test/fixtures/roundtrip-conformance.json
+++ b/test/fixtures/roundtrip-conformance.json
@@ -1,5 +1,5 @@
{
- "_comment": "Shared encoder->decoder roundtrip conformance fixture, covering OP_RETURN, MULTISIGN slots, P2SH/P2WSH multi-chunk (incl. the final-chunk rebalance boundary) and alias-rewrite cases. Generated by test/conformance/generateRoundtripFixture.js; regenerate and review on change. Consumed by the encoder drift-guard test and the decoder real-decode conformance test. gate \"dropped\" cases pin known cross-service data-loss shapes; when the flag-day decoder-acceptance change lands those expectations flip.",
+ "_comment": "Shared encoder->decoder roundtrip conformance fixture, covering OP_RETURN, MULTISIGN slots, P2SH/P2WSH multi-chunk (incl. the final-chunk rebalance boundary), alias-rewrite and TAPROOT-envelope cases. Generated by test/conformance/generateRoundtripFixture.js; regenerate and review on change. Consumed by the encoder drift-guard test and the decoder real-decode conformance test. gate \"dropped\" cases pin known cross-service data-loss shapes; when the flag-day decoder-acceptance change lands those expectations flip.",
"magicWord": "XCHN",
"cases": [
{
@@ -367,5 +367,87 @@
"canonicalDataHex": "42524f4144434153547c307c61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161"
}
}
+ ],
+ "envelopeCases": [
+ {
+ "name": "envelope action-only (SEND)",
+ "encoding": "TAPROOT",
+ "firstInputTxid": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
+ "inputDataHex": "53454e447c307c5449434b7c6d68354345384e626a3338694e443236377334586e7668536d6844573779576336517c313030",
+ "inputRawDataHex": null,
+ "compiledHex": "3253454e447c307c5449434b7c6d68354345384e626a3338694e443236377334586e7668536d6844573779576336517c313030",
+ "compressedPubKey": "027c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c",
+ "internalPubkeyHex": "7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c",
+ "envelopeScriptHex": "0063045843484e0100333253454e447c307c5449434b7c6d68354345384e626a3338694e443236377334586e7668536d6844573779576336517c31303068207c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7cac",
+ "chunkLengths": [
+ 51
+ ],
+ "expected": {
+ "gate": "accepted",
+ "dataHex": "53454e447c307c5449434b7c6d68354345384e626a3338694e443236377334586e7668536d6844573779576336517c313030",
+ "rawDataHex": null
+ }
+ },
+ {
+ "name": "envelope action + rawData (ISSUE + metadata)",
+ "encoding": "TAPROOT",
+ "firstInputTxid": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
+ "inputDataHex": "49535355457c307c5449434b",
+ "inputRawDataHex": "65787472612d6d657461646174612d6279746573",
+ "compiledHex": "0c49535355457c307c5449434b1465787472612d6d657461646174612d6279746573",
+ "compressedPubKey": "027c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c",
+ "internalPubkeyHex": "7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c",
+ "envelopeScriptHex": "0063045843484e0100220c49535355457c307c5449434b1465787472612d6d657461646174612d627974657368207c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7cac",
+ "chunkLengths": [
+ 34
+ ],
+ "expected": {
+ "gate": "accepted",
+ "dataHex": "49535355457c307c5449434b",
+ "rawDataHex": "65787472612d6d657461646174612d6279746573"
+ }
+ },
+ {
+ "name": "envelope multi-chunk BROADCAST",
+ "encoding": "TAPROOT",
+ "firstInputTxid": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
+ "inputDataHex": "42524f4144434153547c307c626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262",
+ "inputRawDataHex": null,
+ "compiledHex": "4db00442524f4144434153547c307c626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262",
+ "compressedPubKey": "027c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c",
+ "internalPubkeyHex": "7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c",
+ "envelopeScriptHex": "0063045843484e01004d08024db00442524f4144434153547c307c626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262624d0802626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262624ca36262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626268207c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7cac",
+ "chunkLengths": [
+ 520,
+ 520,
+ 163
+ ],
+ "expected": {
+ "gate": "accepted",
+ "dataHex": "42524f4144434153547c307c626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262",
+ "rawDataHex": null
+ }
+ },
+ {
+ "name": "envelope final-chunk rebalance boundary (last byte 0x05)",
+ "encoding": "TAPROOT",
+ "firstInputTxid": "a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00",
+ "inputDataHex": "42524f4144434153547c307c646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646405",
+ "inputRawDataHex": null,
+ "compiledHex": "4d0e0442524f4144434153547c307c646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646405",
+ "compressedPubKey": "027c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c",
+ "internalPubkeyHex": "7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c",
+ "envelopeScriptHex": "0063045843484e01004d08024d0e0442524f4144434153547c307c646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464644d070264646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646402640568207c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7c7cac",
+ "chunkLengths": [
+ 520,
+ 519,
+ 2
+ ],
+ "expected": {
+ "gate": "accepted",
+ "dataHex": "42524f4144434153547c307c646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646405",
+ "rawDataHex": null
+ }
+ }
]
}
diff --git a/test/unit/compiledPushSizeConformance.test.js b/test/unit/compiledPushSizeConformance.test.js
index bc53d23..a2cdd41 100644
--- a/test/unit/compiledPushSizeConformance.test.js
+++ b/test/unit/compiledPushSizeConformance.test.js
@@ -96,6 +96,61 @@ describe('compiled-push-size arbiter conformance', function () {
const v = require(VALIDATOR);
assert.strictEqual(v.MAX_COMPILED_ACTION_DATA_LENGTH, XChainDecoder.MAX_ACTION_DATA_LENGTH);
});
+
+ // The envelope band, which the sweep above cannot reach.
+ //
+ // The lane's two sides measure the same bytes with different machinery
+ // ON PURPOSE. compiledPushSize has no OP_PUSHDATA4 branch, so above
+ // 0xffff it under-counts a real compiled push by 2; the encoder corrects
+ // for that in envelopePushSize before comparing against the envelope
+ // ceiling, and the decoder instead refuses to re-measure an envelope
+ // payload at all (the `!envelopeCarrier` guard in parseTransaction).
+ // Both sides carried that reasoning as a COMMENT and neither asserted
+ // it, while the only cross-service sweep stopped at n=8300 - three
+ // orders of magnitude below where the divergence opens and 381,700
+ // bytes below the ENVELOPE_MAX_PAYLOAD ceiling it decides.
+ describe('envelope push band (0xffff .. ENVELOPE_MAX_PAYLOAD)', function () {
+
+ // Straddles every branch the correction touches: the last
+ // OP_PUSHDATA2 length, the first OP_PUSHDATA4 length, the one after
+ // it, a mid-band value, and the ceiling itself.
+ const BAND = [8192, 65534, 65535, 65536, 65537, 200000, 390000];
+
+ it('envelopePushSize equals the real compiled push length across the band', function () {
+ const envelopePushSize = require(VALIDATOR).envelopePushSize;
+ assert.strictEqual(typeof envelopePushSize, 'function',
+ 'xchain-encoder validator must export envelopePushSize');
+ for (const n of BAND) {
+ const compiled = bitcoin.script.compile([buf(n)]).length;
+ assert.strictEqual(envelopePushSize(n), compiled,
+ `envelopePushSize(${n}) must equal bitcoin.script.compile length (${compiled})`);
+ }
+ });
+
+ it('the decoder helper under-counts by exactly 2 above 0xffff, and not below', function () {
+ const envelopePushSize = require(VALIDATOR).envelopePushSize;
+ for (const n of BAND) {
+ const expected = n > 0xffff ? 2 : 0;
+ assert.strictEqual(envelopePushSize(n) - pushSize(n), expected,
+ `at ${n} the encoder envelope correction must be exactly ${expected} bytes ` +
+ `(got ${envelopePushSize(n) - pushSize(n)}); this is the gap that makes the ` +
+ 'decoder refuse to re-measure an envelope payload');
+ }
+ });
+
+ it('re-measuring at the ceiling would under-count, which is why the decoder does not', function () {
+ const C = require('../../src/protocol/constants.js');
+ assert.strictEqual(C.ENVELOPE_MAX_PAYLOAD, 390000);
+ // The negative control for the guard above: state the failure the
+ // `!envelopeCarrier` branch exists to avoid, as arithmetic rather
+ // than as a comment. If compiledPushSize ever grew a PUSHDATA4
+ // band, this assertion is what says so.
+ const real = bitcoin.script.compile([buf(C.ENVELOPE_MAX_PAYLOAD)]).length;
+ assert.strictEqual(real - pushSize(C.ENVELOPE_MAX_PAYLOAD), 2,
+ 'compiledPushSize must still under-count a ceiling-sized push by 2; the decoder ' +
+ 'skips the re-measure on that exact basis');
+ });
+ });
});
// The OP_PUSHDATA2 overhead used to be a bare `+ 3` literal here, invisible to any
diff --git a/test/unit/roundtripConformance.test.js b/test/unit/roundtripConformance.test.js
index 1e44f10..41546e2 100644
--- a/test/unit/roundtripConformance.test.js
+++ b/test/unit/roundtripConformance.test.js
@@ -62,10 +62,21 @@ const SOURCE_ADDRESS = 'mh5CE8Nbj38iND267s4XnvhSmhDW7yWc6Q'
const DUMMY_SIG = Buffer.concat([Buffer.from([0x30]), Buffer.alloc(70, 0xab)])
const DUMMY_PUBKEY = Buffer.concat([Buffer.from([0x02]), Buffer.alloc(32, 0xcd)])
-// Block height these cases parse at. Below every chain's Taproot-envelope
-// recognition height, so the envelope surface stays inert and these legacy
-// carriers parse exactly as they do on the shipped fleet today.
-const PRE_ENVELOPE_HEIGHT = 0
+// Block height these cases parse at. bitcoin-regtest's Taproot-envelope
+// recognition height is 0 (ENVELOPE_RECOGNITION_ACTIVATION in
+// src/protocol/constants.js: testnet and regtest are genesis-active, the flag
+// day only ever applied to mainnet) and the predicate is
+// `blockHeight >= activationHeight`, so the envelope surface is ACTIVE here.
+// The envelope lane below needs that; the legacy carriers are unaffected,
+// because recognition is a pure witness pattern match and none of their
+// spend-side fillers match the grammar.
+//
+// This constant was called PRE_ENVELOPE_HEIGHT and documented as sitting below
+// every chain's recognition height. It never did on this decoder: the comment
+// described an inert surface while the suite ran against a live one, so a
+// regression that pulled a legacy carrier into the envelope branch would have
+// read as impossible here rather than being caught.
+const PARSE_HEIGHT = 0
// Only the node RPC is faked. getSourceFromOutput, the pubkey extraction and
// every gate stay on the production path; the connector returns a real
@@ -138,6 +149,27 @@ function buildP2shTransaction (c, chunkCount) {
return tx
}
+// Taproot-envelope reveal: ins[0] spends the commit output, and its witness is
+// the BIP341 script-path stack the envelope grammar is matched against, indexed
+// from the END - [..., , ]. The control
+// block is leaf version 0xc0 (parity in the low bit) followed by the 32-byte
+// internal key, with no merkle path, which is what a single-leaf commit spends.
+// No annex: an annex-bearing stack is never an envelope (spec §3.8).
+//
+// ins[0] MUST be the envelope input (§3.5) and no other carrier may be present,
+// or the parse rejects the whole envelope deterministically, so this builder
+// pays to a plain address rather than adding an OP_RETURN marker the way the
+// P2SH lane does.
+function buildEnvelopeTransaction (decoder, c) {
+ const envelopeScript = Buffer.from(c.envelopeScriptHex, 'hex')
+ const controlBlock = Buffer.concat([Buffer.from([0xc0]), Buffer.from(c.internalPubkeyHex, 'hex')])
+ const tx = new bitcoin.Transaction()
+ tx.addInput(reversedTxid(c.firstInputTxid), 0)
+ tx.ins[0].witness = [DUMMY_SIG, envelopeScript, controlBlock]
+ tx.addOutput(bitcoin.address.toOutputScript(SOURCE_ADDRESS, decoder.network), 1000)
+ return tx
+}
+
// Expected fate of each fixture case once it reaches the storage gate. Only the
// FATE is pinned here; the stored bytes themselves are always derived from the
// fixture's own encoder inputs below, so this table can never drift into being a
@@ -168,7 +200,12 @@ const STORED_FATE = {
// alias rewrite
'alias rewrite TRANSFER -> SEND': { storable: true, skip: false },
'alias rewrite MSG -> MESSAGE': { storable: true, skip: false },
- 'alias rewrite CAST -> BROADCAST at the compiled ceiling': { storable: true, skip: false }
+ 'alias rewrite CAST -> BROADCAST at the compiled ceiling': { storable: true, skip: false },
+ // TAPROOT envelope
+ 'envelope action-only (SEND)': { storable: true, skip: false },
+ 'envelope action + rawData (ISSUE + metadata)': { storable: true, skip: false },
+ 'envelope multi-chunk BROADCAST': { storable: true, skip: false },
+ 'envelope final-chunk rebalance boundary (last byte 0x05)': { storable: true, skip: false }
}
// The stored ACTION string this case must produce: the encoder's own input,
@@ -184,7 +221,7 @@ function expectedStoredRawDataHex (c) {
// Drive one fixture case all the way to the record the row INSERT receives.
async function storedRecordFor (decoder, db, transaction) {
- const parseResult = await decoder.parseTransaction(transaction, new Set(), db, PRE_ENVELOPE_HEIGHT)
+ const parseResult = await decoder.parseTransaction(transaction, new Set(), db, PARSE_HEIGHT)
const storable = decoder.hasStorableContent(parseResult)
const record = storable
? decoder.buildStoredActionRecord(parseResult, transaction.getId(), false)
@@ -239,7 +276,7 @@ describe('roundtrip conformance fixture: every case reaches the stored record',
it('pins a stored-record expectation for every fixture case, and no stale ones', function () {
const fixtureNames = []
- for (const key of ['cases', 'multisignCases', 'p2shCases', 'aliasCases']) {
+ for (const key of ['cases', 'multisignCases', 'p2shCases', 'aliasCases', 'envelopeCases']) {
assert.ok(fixture[key].length > 0, `fixture.${key} is empty`)
for (const c of fixture[key]) fixtureNames.push(c.name)
}
@@ -266,6 +303,45 @@ describe('roundtrip conformance fixture: every case reaches the stored record',
it('drives every alias case to the record the row INSERT receives', async function () {
for (const c of fixture.aliasCases) await assertCase(decoder, db, c, buildOpReturnTransaction(c))
})
+
+ it('drives every TAPROOT envelope case to the record the row INSERT receives', async function () {
+ for (const c of fixture.envelopeCases) {
+ await assertCase(decoder, db, c, buildEnvelopeTransaction(decoder, c))
+ }
+ })
+
+ it('recognizes each envelope as the carrier, with the envelope ceiling', async function () {
+ // The lane's own routing, not shared with any other group: recognition must
+ // actually fire (a witness that stopped matching would silently fall back to
+ // "no carrier", and assertCase's dropped-gate branch would pass on an empty
+ // payload for the wrong reason), and the per-encoding ceiling must be the
+ // envelope one rather than the 8,192-byte legacy cap.
+ for (const c of fixture.envelopeCases) {
+ const tx = buildEnvelopeTransaction(decoder, c)
+ const parseResult = await decoder.parseTransaction(tx, new Set(), db, PARSE_HEIGHT)
+ assert.strictEqual(parseResult.envelope, true, `${c.name}: not recognized as an envelope carrier`)
+ assert.strictEqual(parseResult.payloadCeiling, XChainDecoder.ENVELOPE_MAX_PAYLOAD,
+ `${c.name}: envelope must carry the envelope payload ceiling`)
+ }
+ })
+
+ it('reassembles the envelope payload as the encoder compiled it, chunk boundaries included', async function () {
+ // The envelope payload is raw (spec §3.3), so the decoder's reassembly is a
+ // plain concat of the leaf's payload pushes. Assert against the fixture's
+ // compiled stream rather than against the parsed ACTION, so a chunk dropped
+ // or reordered at a 520-byte boundary fails here even when the surviving
+ // prefix would still decompile to something.
+ for (const c of fixture.envelopeCases) {
+ const detected = decoder.detectEnvelopeWitness([
+ DUMMY_SIG,
+ Buffer.from(c.envelopeScriptHex, 'hex'),
+ Buffer.concat([Buffer.from([0xc0]), Buffer.from(c.internalPubkeyHex, 'hex')])
+ ])
+ assert.ok(detected != null, `${c.name}: witness did not match the envelope grammar`)
+ assert.strictEqual(detected.payload.toString('hex'), c.compiledHex,
+ `${c.name}: reassembled envelope payload diverges from the encoder's compiled stream`)
+ }
+ })
})
describe('roundtrip conformance fixture: stored-record invariants', function () {
From ec53513d31a7323e0370f0f08540c9ad704f98cb Mon Sep 17 00:00:00 2001
From: J-Dog
Date: Tue, 25 Aug 2026 14:42:26 -0700
Subject: [PATCH 9/9] chore(release): v0.11.0
---
CHANGELOG.md | 17 ++++++++++++-----
package-lock.json | 4 ++--
package.json | 2 +-
3 files changed, 15 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d65103c..1e0396d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,15 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
-
-### Fixed
-- The migrations this service asserts at startup are now declared to the deploy tool, and one whose end state already holds is recorded as applied without re-running its statement.
+## [0.11.0] - 2026-08-25
### Added
- The API serves a `getmempool` method so remote explorers can read the live mempool, including the node's total unconfirmed count.
- Mempool rows record when this decoder first observed them, applied as an automatic additive migration.
+### Changed
+- Updated the BTC mainnet validator reward pool address.
+- Moved the BTC, LTC and DOGE testnet genesis start points forward to just under the live chain tip and regenerated the consensus pin, so the public testnet launches with no pre-announcement test history.
+
+### Fixed
+- The migrations this service asserts at startup are now declared to the deploy tool, and one whose end state already holds is recorded as applied without re-running its statement.
+- Cumulative log-shipper totals are now reported as counters instead of gauges, so rate queries over them return correct values instead of being undefined.
+- Mempool counts and feeds now include only action-carrying rows, instead of every transaction the node's mempool holds.
+- A malformed or blank RPC timeout environment variable no longer silently disables the request timeout, which could leave a stalled node connection hanging indefinitely.
+- RPC error responses from newer node software that reply with HTTP 200 (Bitcoin Core 28 and later) are now classified and retried the same as other RPC failures.
+
## [0.10.0] - 2026-08-18
Consensus-affecting changes in this release ship behind per-chain activation
@@ -96,7 +104,6 @@ set of software rather than a rough era.
- The `migrate.js` CLI is covered by unit tests.
- The committed-migration DDL guard splits statements quote-aware.
-
## [1.11.14] - 2026-06-20
### Added
diff --git a/package-lock.json b/package-lock.json
index 6fd9dc9..d9c677d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "xchain-decoder",
- "version": "0.10.0",
+ "version": "0.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xchain-decoder",
- "version": "0.10.0",
+ "version": "0.11.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^1.18.1",
diff --git a/package.json b/package.json
index 4619262..a72c194 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "xchain-decoder",
"description": "xchain-decoder decodes XChain platform transactions from a given blockchain and populates a database with the decoded data.",
- "version": "0.10.0",
+ "version": "0.11.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",