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: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +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]
## [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
Expand Down Expand Up @@ -93,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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
<img src="https://img.shields.io/badge/coverage-unit%20%7C%20integration%20%7C%20e2e%20%7C%20security%20%7C%20fuzz%20%7C%20chaos%20%7C%20mutation%20%7C%20regression%20%7C%20benchmarks%20%7C%20smoke-brightgreen" alt="Coverage">
</p>

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
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.10.0",
"version": "0.11.0",
"license": "AGPL-3.0-or-later",
"repository": {
"type": "git",
Expand Down
53 changes: 50 additions & 3 deletions src/BlockchainConnector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
module.exports.skipAuxPow = skipAuxPow
// Exported for the env-parsing regression test.
module.exports.envInt = envInt
42 changes: 32 additions & 10 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, 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')
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions src/coins/BTC.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ module.exports = {
GAS: '1XChain3M4uRwcHqt4XuhVBUQ8cL4qQsA',
DONATE1: '1Donate1GERVKPW6GFQcnGeTa8dgL6Abyp', // Protocol Development
DONATE2: '1Donate2LkbBrsanwCVRPWZCXAqQcvcqGz', // Community Development
FEE_DESTINATION: '1FeesxM9LTEjBYVTkynK6jfDBgvksuh2WL', // native-fee destination (env-overridable)
REWARD: '1rewardsZAyeuLeFJKoAepYiNN5N6uSzn', // validator reward pool (COLLECT)
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
},
// Genesis ledger bootstrap pin (Counterparty name carry-forward).
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading