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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.26.0] - 2026-10-20

### Added
- Exposed the reorg-halt state.

### Changed
- Gated the P2WSH source walk-back by chain activation.
- Armed exact-integer dispenser expiration on regtest from genesis.
- Pinned the decoder runtime to the Node 22 glibc image.
- Armed DISPENSER_WIDE_EXPIRATION_GATES and EXACT_INTEGER_EXPIRATION_ACTIVATION on testnet at block time 1792605600.

### Fixed
- Hardened migration guards, duplicate output handling and mempool re-parsing.

## [0.25.0] - 2026-10-18

### Changed
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS dependencies
FROM node:22-bookworm-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS dependencies
WORKDIR /XChainDecoder
COPY ./package.json /XChainDecoder/package.json
COPY ./package-lock.json /XChainDecoder/package-lock.json
RUN npm ci --omit=dev

FROM node:25.9.0@sha256:78839ac448c23517f8eab2e8f7943d9b4f73979eb7f8bed2c73dbf72ff869e7b AS decoder
FROM node:22-bookworm-slim@sha256:c3de60bf2f9dd0ac6370e6117950ff62d6e339527e7472301c9c78a017978392 AS decoder
WORKDIR /XChainDecoder
COPY ./package.json /XChainDecoder/package.json
COPY ./package-lock.json /XChainDecoder/package-lock.json
Expand Down
781 changes: 537 additions & 244 deletions bin/pins/at1-suite-titles.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions bin/pins/suite-title-renames.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"test/unit/db_queries.test/04_delete_block_by_index.test.js": {
"Database#deleteBlockByIndex() executes 4 DELETE queries and returns true on success": "Database#deleteBlockByIndex() executes 5 DELETE queries and returns true on success"
},
"test/unit/db_queries.test/15_delete_and_compare_txs_not_in_list.test.js": {
"Database#deleteAndCompareTxsNotInList() returns transactionsDeleted=0 on query error": "Database#deleteAndCompareTxsNotInList() rejects on query error, leaving txidList whole and releasing the connection"
},
"test/unit/db_queries.test/20_error_path_transaction_connection_branches.test.js": {
"Database error-path transactionConnection branches createAddress: swallows INSERT error even with active transactionConnection": "Database error-path transactionConnection branches createAddress: rethrows an INSERT error inside an active transaction so the block rolls back"
},
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.25.0",
"version": "0.26.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
Expand Down
5 changes: 3 additions & 2 deletions src/XChainDecoder/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,9 @@ const SYNCED_THRESHOLD = 3 //Maximum blocks behind to be synced
// money-bearing dispenser on the reorged node. Standard networks use 126 and
// Litecoin testnet uses 5006. Invariant: SAFE_DEPTH >= matching undo window +
// margin. The +6 margin means a small undo-window re-tune
// cannot land exactly at the purge threshold; dispenserSafeDepth.test.js
// enforces the invariant with a conformance read of undo-blocks.js.
// cannot land exactly at the purge threshold; test/unit/dispenser_safe_depth.test.js
// enforces the invariant with a conformance read of DEFAULT_UNDO_BLOCKS in
// xchain-utxo-tracker/src/chain/undo_blocks.js.
// Purging deeper is the conservative direction (rows are merely retained longer
// before hard-purge; expiry semantics and action evaluation are unchanged).
const DISPENSER_EXPIRE_SAFE_DEPTH = 126 // 120 (deepest standard window) + 6 margin
Expand Down
10 changes: 6 additions & 4 deletions src/XChainDecoder/mempool_refresh.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,22 +196,24 @@ function* storeMempoolTransaction(nodeTxid, nextTxHex, nextTxHexIndex, openDispe
// confirmed twin silently mismatches (uuid:26220713). A rejected ACTION
// on a money-bearing tx blanks to '' (never SQL NULL) for the same reason.
const stored = admitMempoolAction.call(this, parseResult, nextTransactionHash)
if (stored.skip) return false
// Store a gate-skipped tx blanked as its seen-set entry (the block path rejects it
// once; without a row every poll re-fetches it and re-counts its parse error).
const blanked = stored.skip

if (!(yield this.mempoolDb.insertMempoolTransaction({
hash: nextTransactionHash,
source: parseResult["source"],
destination: parseResult["destination"],
amount: parseResult["amount"],
fee: 0,
data: stored.data,
raw_data: stored.rawData
data: blanked ? "" : stored.data,
raw_data: blanked ? null : stored.rawData

}))) {
yield this.sleep(3000)
return false
} else {
return !stored.unattributed && (parseResult["data"] != null) && (parseResult["data"].length > 0)
return !blanked && !stored.unattributed && (parseResult["data"] != null) && (parseResult["data"].length > 0)
}
}

Expand Down
9 changes: 8 additions & 1 deletion src/XChainDecoder/payload_helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ function nodeStillCatchingUp(info){
return !!info && info["initialblockdownload"] === true
}

// Check a getblockchaininfo reply carries numeric `blocks` and `verificationprogress`.
// A trimmed proxy reply makes `undefined < threshold` false and passes the not-synced
// gate, so both node-tip paths (fetchChainTip, readReorgTip) refuse it through this.
function blockchainInfoWellFormed(info){
return !!info && typeof info["blocks"] === 'number' && typeof info["verificationprogress"] === 'number'
}

// Compiled size of a single script push once bitcoin.script.compile adds its
// length prefix: a direct push opcode for <=75 bytes, OP_PUSHDATA1 (+2) for
// <=255, or OP_PUSHDATA2 (+3) beyond that. Single source for measuring both
Expand Down Expand Up @@ -103,4 +110,4 @@ function bigIntBufferutilsActive(bufferutils){
return false
}
}
module.exports = { nodeStillCatchingUp, compiledPushSize, canonicalizeActionPayload, bigIntBufferutilsActive }
module.exports = { nodeStillCatchingUp, blockchainInfoWellFormed, compiledPushSize, canonicalizeActionPayload, bigIntBufferutilsActive }
15 changes: 11 additions & 4 deletions src/XChainDecoder/reorg_verification.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const { format: formatLogLine } = require('node:util')
const { chainTierMismatch } = require('../protocol/chain_identity')
const { logger, DISPENSER_EXPIRE_SAFE_DEPTH, MIN_VERIFICATION_PROGRESS_TO_PARSE } = require('./constants.js')
const { haltReorg } = require('./reorg_halt.js')
const { nodeStillCatchingUp } = require('./payload_helpers.js')
const { nodeStillCatchingUp, blockchainInfoWellFormed } = require('./payload_helpers.js')

function safeDepthFor(decoder){
return decoder.dispenserExpireSafeDepth || DISPENSER_EXPIRE_SAFE_DEPTH
Expand Down Expand Up @@ -175,11 +175,18 @@ function nodeTipStillCatchingUp(info){
}

// Read the node tip through the block loop's gates. Answers nodeTip unchanged when the
// node cannot be read or a gate refuses the reply (foreign tier, foreign genesis, a node
// still catching up), so a refused reading is never below the tip the walk already has.
// node cannot be read or a gate refuses the reply (malformed shape, foreign tier, foreign
// genesis, a node still catching up), so a refused reading is never below the tip the walk
// already has.
async function readReorgTip(nodeTip){
try {
const info = await this.connector.getBlockchainInfo()
// Refuse the same malformed shape fetchChainTip refuses: without a numeric
// verificationprogress the catching-up gate below cannot fire.
if (info && !blockchainInfoWellFormed(info)){
this.logWarn('reorg: ignoring a malformed tip refresh (missing or non-numeric blocks/verificationprogress)')
return nodeTip
}
// Apply the block loop's chain-identity gate here too. This is
// the SECOND path a node tip reaches nodeTip, and nodeTip is exactly what
// the above-tip branch deletes valid local blocks against, so a foreign
Expand All @@ -190,7 +197,7 @@ async function readReorgTip(nodeTip){
const reorgChainMismatch = info ? chainTierMismatch(this.consensusNetwork, info["chain"]) : null
if (reorgChainMismatch){
this.logError('reorg: ignoring a tip refresh from a foreign endpoint: ' + reorgChainMismatch)
} else if (info && typeof info.blocks === 'number') {
} else if (info) {
// Tier agreement is not chain identity: a same-tier foreign node (BTC-mainnet
// and DOGE-mainnet both report chain="main") passes the tier gate above, so
// re-prove the chain with the genesis pin too, exactly as the block loop does
Expand Down
27 changes: 21 additions & 6 deletions src/XChainDecoder/source_resolution.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const crypto = require('crypto')
const bitcoin = require('bitcoinjs-lib')
const { format: formatLogLine } = require('node:util')
const { logger } = require('./constants.js')
const { P2WSH_WALKBACK_SEGWIT_GATE_ACTIVATION } = require('../protocol/constants/activation.js')

function* fetchPrevoutTransaction(txId, outputIndex){
// A prevout lookup that FAILS is not a prevout that does not exist. Swallowing
Expand Down Expand Up @@ -61,7 +62,7 @@ function* fetchPrevoutTransaction(txId, outputIndex){
return this.xchainBlockDecoder.transactionFromHex(outputRawTransaction)
}

function isRevealCarrierScript(script){
function revealCarrierType(script){
let isP2sh = (
(script.length == 23) //23 bytes for a standard p2sh
&& (script[0] == 0xa9) //OP_HASH160
Expand All @@ -73,7 +74,19 @@ function isRevealCarrierScript(script){
&& (script[0] == 0x00) //OP_0 (witness v0)
&& (script[1] == 0x20) //PUSH 32 bytes
)
return isP2sh || isP2wsh
if (isP2sh) return 'p2sh'
if (isP2wsh) return 'p2wsh'
return null
}

function p2wshWalkbackEnabled(decoder, blockHeight){
if (decoder.network.supportsSegwit !== false) return true
const coinMap = P2WSH_WALKBACK_SEGWIT_GATE_ACTIVATION[decoder.coinTick]
const activation = coinMap ? coinMap[decoder.consensusNetwork] : null
const gateActive = typeof activation === 'number'
&& typeof blockHeight === 'number'
&& blockHeight >= activation
return !gateActive
}

function* fetchCommitFundingOutput(outputTransaction){
Expand All @@ -99,7 +112,7 @@ function* fetchCommitFundingOutput(outputTransaction){
return prevTransaction.outs[prevOutputIndex]
}

function* resolveSourceFromOutput(txId, outputIndex, capture){
function* resolveSourceFromOutput(txId, outputIndex, capture, blockHeight){
let source = null
let output = null
let outputTransaction = null
Expand All @@ -122,7 +135,9 @@ function* resolveSourceFromOutput(txId, outputIndex, capture){
//THAT prev output's address (the funder/issuer). Without the P2WSH branch
//the source of every P2WSH-encoded action resolved to the bech32 script
//address (bcrt1q...), which holds no gas → spurious "insufficient funds (FEE)".
if (isRevealCarrierScript(script)){
const carrierType = revealCarrierType(script)
if (carrierType === 'p2sh'
|| (carrierType === 'p2wsh' && p2wshWalkbackEnabled(this, blockHeight))){
output = yield* fetchCommitFundingOutput.call(this, outputTransaction)
}

Expand Down Expand Up @@ -191,13 +206,13 @@ module.exports = {
// 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){
async getSourceFromOutput(txId, outputIndex, capture = null, blockHeight){
// The lookup steps are generators that yield each node read to this
// loop, so the method suspends only while a read is pending, once per
// read, and the decode and walk-back after a read run in the same
// stretch. An async helper awaited here would add a suspension after
// each read settles.
const steps = resolveSourceFromOutput.call(this, txId, outputIndex, capture)
const steps = resolveSourceFromOutput.call(this, txId, outputIndex, capture, blockHeight)
let next = steps.next()
while (!next.done){
let settled
Expand Down
12 changes: 5 additions & 7 deletions src/XChainDecoder/tip_refresh.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
const { format: formatLogLine } = require('node:util')
const { chainTierMismatch, chainFieldMissing, chainGenesisUnpinned } = require('../protocol/chain_identity')
const { logger, BLOCKCHAIN_INFO_REFRESH_MS, MIN_VERIFICATION_PROGRESS_TO_PARSE } = require('./constants.js')
const { nodeStillCatchingUp } = require('./payload_helpers.js')
const { nodeStillCatchingUp, blockchainInfoWellFormed } = require('./payload_helpers.js')
const { parkOrRethrow } = require('./sync_loop.js')

// lastBlockchainInfo is the reply fetchChainTip just stored on the loop, passed in
Expand Down Expand Up @@ -104,12 +104,10 @@ async function fetchChainTip(loop){
// response or a per-coin getblockchaininfo variant could omit these
// fields; without this guard `undefined < 0.99` is false (the
// not-synced gate silently passes) and `blocks` becomes undefined
// (every later height comparison quietly goes wrong). Mirror the
// typeof-number discipline verifyReorg's tip refresh already applies
// and treat a malformed result like the RPC-failure branch below.
if (!loop.lastBlockchainInfo
|| typeof loop.lastBlockchainInfo["blocks"] !== 'number'
|| typeof loop.lastBlockchainInfo["verificationprogress"] !== 'number'){
// (every later height comparison quietly goes wrong). verifyReorg's
// mid-walk tip refresh applies the same shared check, and a malformed
// result is treated like the RPC-failure branch below.
if (!blockchainInfoWellFormed(loop.lastBlockchainInfo)){
logger.error("Malformed getblockchaininfo response (missing or non-numeric 'blocks'/'verificationprogress'). Trying again...")
loop.lastBlockchainInfo = null
await this.sleep(3000)
Expand Down
19 changes: 13 additions & 6 deletions src/XChainDecoder/transaction_ingest.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,17 @@ async function storeDispenseOutput(loop, nextOutput, nextBlockHeight){
logger.error(`insertTransactionOutput (dispense) failed at block ${nextBlockHeight}; block rolled back, retrying`)
return 'rollback'
}
if (insertResult === this.db.DUPLICATED_TRANSACTION){
logger.warn(`Duplicate transaction_output on insert (block_index=${nextBlockHeight}, tx_index=${loop.lastProcessedTxIndex}, vout=${nextOutput.vout}); possible stale pre-reorg row not cleaned up by deleteBlockByIndex`)
}
if (insertResult === this.db.DUPLICATED_TRANSACTION)
return rollbackDuplicateOutput.call(this, loop, nextOutput, nextBlockHeight, 'dispense')
}

// The parser never yields one (tx_index, vout) twice, so a duplicate output is a row this
// tx_index did not write (an orphan the transactions counter cannot see). Keeping it would
// attach another destination/amount to this transaction, so roll back as insertTransactionRow does.
async function rollbackDuplicateOutput(loop, nextOutput, nextBlockHeight, kind){
logger.error(`insertTransactionOutput (${kind}) hit an existing transaction_outputs row in block ${nextBlockHeight} (tx_index ${loop.lastProcessedTxIndex}, vout ${nextOutput.vout}); possible orphaned row, rolling the block back`)
await this.db.endTransaction()
return 'rollback'
}

//Store payment outputs the indexer needs to read:
Expand Down Expand Up @@ -256,9 +264,8 @@ async function capturePaymentOutputs(loop, block, parseResult, nextTransactionHa
logger.error(`insertTransactionOutput (payment) failed at block ${nextBlockHeight}; block rolled back, retrying`)
return 'rollback'
}
if (insertResult === this.db.DUPLICATED_TRANSACTION){
logger.warn(`Duplicate transaction_output on insert (block_index=${nextBlockHeight}, tx_index=${loop.lastProcessedTxIndex}, vout=${nextOutput.vout}); possible stale pre-reorg row not cleaned up by deleteBlockByIndex`)
}
if (insertResult === this.db.DUPLICATED_TRANSACTION)
return rollbackDuplicateOutput.call(this, loop, nextOutput, nextBlockHeight, 'payment')
}
}
return commands
Expand Down
6 changes: 3 additions & 3 deletions src/XChainDecoder/transaction_parsing.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ function buildParseResult(dataBuffer, compiledDataLength, rawData, source, sourc
}
}

function* resolveXChainTransaction(transaction, db, nextTxId, firstInputTxId, dispenseOutputs, paymentOutputs, parsed){
function* resolveXChainTransaction(transaction, db, blockHeight, nextTxId, firstInputTxId, dispenseOutputs, paymentOutputs, parsed){
let { source, dataBuffer, rawData, getSource, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction } = parsed
// compiledDataLength starts as the raw accumulated byte count.
// For P2SH/P2WSH/OP_RETURN this equals the compiled push size (the
Expand Down Expand Up @@ -181,7 +181,7 @@ function* resolveXChainTransaction(transaction, db, nextTxId, firstInputTxId, di
if (getSource && (source == null)){
source = envelopeCarrier
? yield this.getEnvelopeSourceFromCommit(envelopeCommitTransaction)
: yield this.getSourceFromOutput(firstInputTxId, transaction.ins[0].index, sourceCommitCapture)
: yield this.getSourceFromOutput(firstInputTxId, transaction.ins[0].index, sourceCommitCapture, blockHeight)
}

// Read the key from the input that spent the source's UTXO. For an envelope that
Expand Down Expand Up @@ -239,7 +239,7 @@ function* parseXChainTransaction(transaction, openDispenserAddresses, db, blockH

;({ dataBuffer, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction } = yield* arbitrateEnvelope.call(this, envelopeActive, envelopeInputs, blockHeight, nextTxId, firstInputTxId, otherCarrierRecognized, { dataBuffer, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction }))

return yield* resolveXChainTransaction.call(this, transaction, db, nextTxId, firstInputTxId, dispenseOutputs, paymentOutputs, { source, dataBuffer, rawData, getSource, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction })
return yield* resolveXChainTransaction.call(this, transaction, db, blockHeight, nextTxId, firstInputTxId, dispenseOutputs, paymentOutputs, { source, dataBuffer, rawData, getSource, p2shFundingTxId, envelopeCarrier, envelopeCommitTransaction })
}

function* parseTransactionSteps(transaction, openDispenserAddresses, db, blockHeight){
Expand Down
Loading
Loading