Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +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).

## [0.10.0] - 2026-08-18

Consensus-affecting changes in this release ship behind per-chain activation
points; behavior below each activation height is unchanged.

### Fixed
- Two dispenser defects that spend a payer's own coin and give nothing back are corrected behind activation gates, armed on testnet from genesis and disarmed on mainnet.
- Chain identity is re-proven on the reorg tip re-read, so a wrong-chain answer cannot slip in during a reorganization.
- A detected reorg is announced on stderr at warn and the block-hash retry loop at error, so alerting rules that only read warn-and-above can see them; reorgs are also counted.
- The dispenser expiration wire offsets are named constants derived from the indexer's live formats, instead of bare literals with a comment as their only contract.
- Sub-command capture arms on exactly the same boundary as the batch settlement ledger on every network, closing the consensus window an ordering-only check allowed.
- The container no longer runs npm as its first process.
- Code-review round fixes across the decode path (two rounds, 14 files).

### Security
- Raised the brace-expansion and js-yaml dependency floors and the advisory guards that pin them.

## [0.9.0] - 2026-08-14

First release of the XChain Platform release train. Every component in the train
Expand Down
8 changes: 7 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ COPY ./src /XChainDecoder/src
COPY ./src/bufferutils.js /XChainDecoder/node_modules/bitcoinjs-lib/src/bufferutils.js
COPY ./.en[v] /XChainDecoder/.env

CMD ["npm", "run", "api"]
# Exec-form node, not `npm run api` (which is this exact command). npm builds an
# npm -> sh -c -> node tree and no wrapper forwards signals, so `docker stop`
# kills npm while node is never told anything (measured on the regtest encoder,
# xchain-encoder/Dockerfile). This image registers real drain work on SIGTERM
# (src/api.js: flip decoderRunning, then decoder.stop()), which only runs when
# node is PID 1 and receives the signal itself.
CMD ["node", "./src/api.js"]
11 changes: 2 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
{
"name": "xchain-decoder",
"description": "xchain-decoder decodes XChain platform transactions from a given blockchain and populates a database with the decoded data.",
"version": "0.9.0",
"version": "0.10.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
"url": "https://github.com/XChain-Platform/xchain-decoder.git"
},
"dependencies": {
"axios": "^1.18.1",
"binary-search": "^1.3.6",
"bip32": "4.0.0",
"bip39": "^3.1.0",
"bitcoinjs-lib": "6.1.7",
Expand Down
14 changes: 12 additions & 2 deletions src/BlockchainConnector.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,25 @@ function sanitizeRpcError(error){
// with HTTP 200 and result: null, in which case the real cause (Block height
// out of range, Loading block index..., auth/queue errors) must not be masked
// by a hand-written placeholder. `label` is the existing per-method message.
//
// "Missing" is PRESENCE, not truthiness: a JSON-RPC success carries a `result`
// member that may legitimately be 0, false or "", and only undefined/null mean
// the node sent no result. Every method funnelled through here today answers
// with an object, an array or a non-empty hex string, so this changes nothing
// for them; it is the guard the first falsy-answering method (a count at
// genesis, a boolean) would otherwise be misread by and burned through the
// caller's retry loop as a hard RPC failure.
function rpcResult(response, label) {
const rpcError = response && response.data && response.data.error
if (rpcError) {
const code = (rpcError.code !== undefined) ? rpcError.code : 'unknown'
const message = (typeof rpcError.message === 'string') ? rpcError.message : JSON.stringify(rpcError)
throw new Error(`${label}: RPC error ${code}: ${message}`)
}
if (!response || !response.data || !response.data.result) throw new Error(label)
return response.data.result
if (!response || !response.data) throw new Error(label)
const result = response.data.result
if (result === undefined || result === null) throw new Error(label)
return result
}

// Decode a Bitcoin-style varint from `buf` at `offset`.
Expand Down
49 changes: 37 additions & 12 deletions src/XChainDecoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = require('./oracleFeeOutput')
const { isOracleFeeCaptureActive, isOracleFeeSetCaptureActive, oracleAddressFromCreate, isCompactedOracleAddress, 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')
Expand Down Expand Up @@ -284,6 +284,10 @@ class XChainDecoder {
// Coin/network-prefixed loggers so cadence/reorg/stall lines are self-describing
// even when a log pipeline strips container labels. Reads the fields at call time.
this.log = (...args) => console.log('[' + this.coinTick + '/' + this.consensusNetwork + ']', ...args)
// Warn exists so a notable-but-not-failed event (a reorg starting) can reach a
// warn-and-above alerting rule without being dressed up as an error. console.log
// writes to stdout, which those rules do not read.
this.logWarn = (...args) => console.warn('[' + this.coinTick + '/' + this.consensusNetwork + ']', ...args)
this.logError = (...args) => console.error('[' + this.coinTick + '/' + this.consensusNetwork + ']', ...args)

// Native-coin protocol fee destination address for this coin+network. When set (not the
Expand Down Expand Up @@ -369,6 +373,15 @@ class XChainDecoder {
this.rpcErrors = 0
this.parseErrors = 0

// Lifetime reorg counters, mirroring xchain-utxo-tracker. Each rolled-back block
// already writes a durable REORG row, but that trace is DB-only: without these a
// metrics-only deployment (no monitor plugin, indexer possibly down) has no
// scrapeable signal for a decoder thrashing through repeated shallow reorgs.
// Counted once per completed verifyReorg run, so count is reorg EVENTS and depth
// is the blocks rolled back by the most recent one.
this.reorgCount = 0
this.lastReorgDepth = 0

// Consecutive block-fetch failures at _fetchErrorHeight. _fetchErrorCount counts
// every failure (operator visibility); _auxPowParseErrorCount counts only the
// AuxPoW-header-strip content faults that may escalate to per-tx block
Expand Down Expand Up @@ -558,7 +571,13 @@ class XChainDecoder {
const status = {
last_processed_block: this.lastProcessedBlockIndex,
node_height: this.blockchainInfoLastBlock,
lag: this.blockchainInfoLastBlock - this.lastProcessedBlockIndex
lag: this.blockchainInfoLastBlock - this.lastProcessedBlockIndex,
// Reorg churn, additive: an operator polling /status sees how often this
// decoder has rolled back and how deep the last one went, without joining
// against the indexer. Absent from the nothing-processed-yet shape above,
// which deliberately reports unknowns rather than zeros.
reorg_count: this.reorgCount,
last_reorg_depth: this.lastReorgDepth
}
if (nodeHeightStale) status.node_height_stale = true
return status
Expand Down Expand Up @@ -1789,7 +1808,7 @@ class XChainDecoder {
try {
blockHashFromNode = await this.connector.getBlockHash(lastBlockIndex)
} catch (err){
console.log("There was a problem trying to get a block hash from the node. Trying again...", err)
console.error("There was a problem trying to get a block hash from the node. Trying again...", err)
// The node's tip may have regressed below lastBlockIndex mid-walk (node
// restart onto a shorter chain, or a second reorg). Against the frozen
// call-time nodeTip that makes getBlockHash(lastBlockIndex) throw "Block
Expand Down Expand Up @@ -1858,6 +1877,10 @@ class XChainDecoder {
// delete (deleteBlockByIndex), so there is no separate end-of-run event to write.
// This is only an ops summary of the completed reorg.
this.log(`reorg: rolled back ${blocksDeleted.length} block(s): ` + JSON.stringify(blocksDeleted.map(b => b.block_index)))
// Once per RUN, never per deleted block: a per-block increment would report a
// single depth-5 reorg as five reorgs and destroy the frequency signal.
this.reorgCount++
this.lastReorgDepth = blocksDeleted.length
}

return true
Expand Down Expand Up @@ -2439,7 +2462,7 @@ class XChainDecoder {
//previousBlockHash is not the same, it must be a reorg
if (previousBlockHash != previousBlock.block_hash){
await this.db.endTransaction()
console.log("A reorg has been detected at block " + nextBlockHeight + ". Cleaning blocks...")
this.logWarn("A reorg has been detected at block " + nextBlockHeight + ". Cleaning blocks...")
const preReorgBlock = lastProcessedBlockIndex
await this.verifyReorg(this.blockchainInfoLastBlock)
// Re-clamp: same as the pre-loop guard and the node-tip regression path.
Expand Down Expand Up @@ -2909,7 +2932,7 @@ class XChainDecoder {
// Treat a missing token OR an empty-string token as an
// omitted EXPIRATION and substitute the same default the
// indexer uses; only a present, non-empty value is validated.
let expirationToken = decodedDataSplit[14]
let expirationToken = decodedDataSplit[V0_EXPIRATION_INDEX]
let expiration
if (expirationToken === undefined || expirationToken === "") {
expiration = this.getDefaultExpiration(block.timestamp)
Expand Down Expand Up @@ -2944,7 +2967,7 @@ class XChainDecoder {
// loop on the same deterministic tx forever.
if (!Number.isSafeInteger(expiration) || expiration < 0) {
this.parseErrors++
console.error(`Skipping dispenser in tx ${nextTransactionHash}: invalid expiration value '${decodedDataSplit[14]}'`)
console.error(`Skipping dispenser in tx ${nextTransactionHash}: invalid expiration value '${decodedDataSplit[V0_EXPIRATION_INDEX]}'`)
} else if (this.dispenserOpensForThisChain(giveCoin, getCoin)){
if (getAddress && getAddress.length > 0 && getAddress.charAt(0) === "^"){
// Fail loud on a compacted `^<id>` GET_ADDRESS. This is a
Expand Down Expand Up @@ -3058,7 +3081,7 @@ class XChainDecoder {
// failing to mirror WOULD close early. An edit that shortens one
// is deliberately not mirrored.
const editSource = parseResult["source"]
const editExpirationToken = decodedDataSplit[4]
const editExpirationToken = decodedDataSplit[V2_EXPIRATION_INDEX]
if (editSource && editSource.length > 0 &&
editExpirationToken !== undefined && editExpirationToken !== ""){
const newExpiration = Number(editExpirationToken)
Expand Down Expand Up @@ -3226,11 +3249,13 @@ class XChainDecoder {

// Dedup + single O(n log n) sort. The old per-txid binary-insert
// (bs + splice) was O(n^2) in mempool size every poll cycle, a CPU
// hazard under a mempool flood. ORDER CONTRACT: descending
// lexicographic, i.e. exactly what the inverted bs comparator
// `needle.localeCompare(element)` produced; db.js
// deleteAndCompareTxsNotInList binary-searches this array with
// that same comparator and silently breaks on any other order.
// hazard under a mempool flood. What the consumer needs is the DEDUP:
// db.js deleteAndCompareTxsNotInList seeds this array into a temp
// table and filters it through a Set, so a repeated txid would be
// fetched and inserted twice. The descending sort is deterministic
// poll-order only (it preserves the order the old bs comparator
// produced, which keeps logs and fixtures comparable); nothing in the
// DB layer searches this array, so no ordering is load-bearing.
rawMempool = Array.from(new Set(rawMempoolUnordered))
.sort((a, b) => b.localeCompare(a))

Expand Down
Loading
Loading