Skip to content
Open
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
56 changes: 37 additions & 19 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"url": "https://github.com/XChain-Platform/xchain-decoder.git"
},
"dependencies": {
"ws": "8.21.3",
"axios": "^1.18.1",
"bip32": "4.0.0",
"bip39": "^3.1.0",
Expand Down
4 changes: 3 additions & 1 deletion src/XChainDecoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const reorgVerificationMethods = require('./XChainDecoder/reorg_verification.js'
const startupMethods = require('./XChainDecoder/startup.js')
const mempoolRefreshMethods = require('./XChainDecoder/mempool_refresh.js')
const { DISPENSER_WIDE_EXPIRATION_ACTIVATION } = require('./protocol/constants.js')
const kaspaSyncMethods = require('./XChainDecoder/kaspa_sync.js')

//We need to init the ecc to parse taproot addresses from output scripts
bitcoin.initEccLib(ecc);
Expand Down Expand Up @@ -305,7 +306,8 @@ Object.assign(XChainDecoder.prototype,
transactionParsingMethods,
reorgVerificationMethods,
startupMethods,
mempoolRefreshMethods)
mempoolRefreshMethods,
kaspaSyncMethods)

// The class IS the export, and everything below hangs off it. Attached with one
// Object.assign rather than a run of `module.exports.X =` lines: `module.exports`
Expand Down
132 changes: 132 additions & 0 deletions src/XChainDecoder/kaspa_sync.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*********************************************************************
*
* 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.
*
*********************************************************************
*
* Kaspa ingest part.
*
* The bitcoinjs families run through runSyncLoop(); Kaspa cannot (blake3
* txids, borsh framing, no raw-hex block RPC), so the family gets its own
* loop that writes the SAME tables. Everything family-specific already lives
* under src/chain/kaspa/, and this part is only the wiring:
*
* this.startKaspaIngest({ rpc | rpcUrl, intervalMs, checkpointFile })
*
* Deliberately NOT started automatically. The decoder has no way to know
* whether it is the writer for this chain in a given deployment (two
* decoders against one database would double-ingest), and a silently started
* background writer is exactly the kind of surprise this codebase avoids
* elsewhere. startup.js calls it once it has decided this process owns the
* Kaspa ingest, and calls stopKaspaIngest() on shutdown so the loop finishes
* its current tick instead of cutting a transaction in half.
*
* The chain blocks come from the node's wRPC endpoint. Pass `rpc` to reuse an
* existing connection, or `rpcUrl` to let the family build one (which loads
* the optional `ws` dependency lazily and fails loudly if it is missing).
********************************************************************/
'use strict';

const { createRpcClient } = require('../chain/kaspa/kaspa_node_client.js');
const { KaspaDecoderService } = require('../chain/kaspa/kaspa_service.js');

module.exports = {
// exported so the platform-logger preference is directly testable
kaspaLogger,
wrap,

// Start the Kaspa loop. Returns the service so a caller can inspect stats,
// and throws with a reason when this instance is already running one.
startKaspaIngest({ rpc = null, rpcUrl = null, intervalMs = 5000, checkpointFile = null,
networkId = null, minConfirmationCount = 10, maxTicks = Infinity, onTick = null } = {}) {

if (this.kaspaService) throw new Error('startKaspaIngest: this decoder is already running a Kaspa ingest');
if (this.xchainBlockDecoder && this.xchainBlockDecoder.wireFormat !== 'kaspa') {
throw new Error('startKaspaIngest: this decoder is configured for wireFormat "'
+ this.xchainBlockDecoder.wireFormat + '", not "kaspa"');
}

const client = rpc || createRpcClient({ url: rpcUrl });
const service = new KaspaDecoderService({
rpc: client,
conn: this.db, // the platform Database exposes query(sql, values)
networkId: networkId || this.network,
minConfirmationCount,
checkpointFile,
logger: kaspaLogger(this.log),
});

this.kaspaService = service;
this.kaspaIngestPromise = (async () => {
try {
if (client.connect) await client.connect();
// service.init() is deliberately NOT called here any more: run() initialises inside its
// own retry loop, so a node that is not up yet is retried instead of rejecting the
// start promise and leaving this chain unread until an operator restarts the process.
await service.run({ intervalMs, maxTicks, onTick });
} finally {
if (client.disconnect) await client.disconnect().catch(() => {});
this.kaspaService = null;
}
})();

return service;
},

// Graceful stop: finishes the tick in flight, then settles.
async stopKaspaIngest() {
if (!this.kaspaService) return false;
this.kaspaService.requestStop();
await this.kaspaIngestPromise.catch(() => {}); // the promise rethrows only for the caller
return true;
},

// Stats for the health/status surface. Null when this instance is not the
// Kaspa writer, which is a normal state, not an error.
kaspaIngestStats() {
return this.kaspaService ? { ...this.kaspaService.stats, ticks: this.kaspaService.ticks,
checkpoint: this.kaspaService.checkpoint } : null;
},
};

// The platform's log() takes a string; the family service wants the four-level
// object shape. Adapt rather than assume either side changes.
// Prefer the platform's own logger (observability/logger), which carries real levels:
// the decoder's this.log is a single info-level sink, so routing errors through it lost
// the level entirely (found in the 2026-10-11 review). Falls back to that sink.
// Resolved at module scope (CODE-STYLE: require inside a body only for a computed path). A
// standalone run may not have the platform's logger on disk, so the failure is captured here
// instead of being thrown: kaspaLogger() then falls back to the sink the decoder hands us.
let platformGetLogger = null;
try { platformGetLogger = require('../observability/logger').getLogger; } catch (error) { platformGetLogger = null; }

function kaspaLogger(fallbackLog) {
if (typeof platformGetLogger === 'function') {
try {
const logger = platformGetLogger();
if (logger) return logger;
} catch (error) {
// the logger exists but cannot initialise here: fall through to the sink
}
}
return typeof fallbackLog === 'function' ? wrap(fallbackLog) : undefined;
}
function wrap(log) {
const call = (level, message, fields) => log.call(null, '[' + level + '] ' + message
+ (fields ? ' ' + JSON.stringify(fields) : ''));
return {
error: (m, f) => call('error', m, f),
warn: (m, f) => call('warn', m, f),
info: (m, f) => call('info', m, f),
debug: (m, f) => call('debug', m, f),
child() { return this; },
};
}
31 changes: 30 additions & 1 deletion src/chain/XChainBlockDecoder.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ const LITECOIN_MWEB_SEGWIT_FLAG = 0x09
// an unimplemented family fails loudly at construction instead of silently
// falling through to the strict default parser and misparsing at its first
// special block.
const HANDLED_WIRE_FORMATS = new Set(['default', 'mweb', 'auxpow'])
const { KaspaBlockDecoder } = require('./kaspa/kaspa_block_decoder.js')
const HANDLED_WIRE_FORMATS = new Set(['default', 'mweb', 'auxpow', 'kaspa']) // 'kaspa' = the new family: blake3 txids, borsh framing, carrier in the transaction payload. See chain/kaspa/.

function readMwebTransactions(bufferReader, block) {
const readTransaction = () => {
Expand Down Expand Up @@ -96,6 +97,23 @@ class XChainBlockDecoder {
'" (network "' + networkName + '"). Declare a handled wireFormat (default/mweb/auxpow) ' +
'on the coin in src/coins before onboarding the chain.')
}
// Kaspa family: kaspad answers with structured wRPC blocks, not raw hex, so this
// decoder keeps a family adapter that presents the same surface to the ingest path
// (see chain/kaspa/kaspa_block_decoder.js, and the delegate tests).
this.kaspa = (this.wireFormat === 'kaspa') ? new KaspaBlockDecoder(networkName) : null
}

// Structured entry points for the Kaspa family. Callers that hold an
// XChainBlockDecoder use these instead of the buffer-based ones, so the family
// switch lives here rather than in every caller.
blockFromObject(rawBlock) {
if (!this.kaspa) throw new Error('XChainBlockDecoder.blockFromObject: only the kaspa wire-format takes structured blocks')
return this.kaspa.blockFromObject(rawBlock)
}

transactionFromObject(rawTransaction) {
if (!this.kaspa) throw new Error('XChainBlockDecoder.transactionFromObject: only the kaspa wire-format takes structured transactions')
return this.kaspa.transactionFromObject(rawTransaction)
}

blockFromHex(blockHex){
Expand Down Expand Up @@ -149,7 +167,18 @@ class XChainBlockDecoder {
// This Block contains a witness commit
if (witnessCommit) block.witnessCommit = witnessCommit;
return block;
case "kaspa":
// Kaspa is not a bitcoinjs family (blake3 txids, borsh framing, carrier in the
// transaction payload). Parsing these bytes with bitcoinjs produces rows that
// look valid — the failure mode this file header warns about — so the byte-level
// entry points stay loud. The structured entry points live on this class
// (blockFromObject / transactionFromObject) and delegate to chain/kaspa/, which
// presents the same surface the ingest path already consumes.
throw new KaspaBlockDecoder('kaspa').blockFromBuffer(buffer)
default:
// The bitcoinjs families (bitcoin, dogecoin): unchanged, and it must stay the
// default arm — putting the kaspa case above it once made every bitcoin and
// dogecoin block take the Kaspa path (caught by test/smoke/block_decoder.test.js).
return bitcoinjs.Block.fromBuffer(buffer)
}
}
Expand Down
10 changes: 8 additions & 2 deletions src/chain/crypto_networks.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@

const coins = require('../coins');

const SUPPORTED = 'bitcoin-mainnet, bitcoin-testnet, bitcoin-regtest, dogecoin-mainnet, ' +
const SUPPORTED = 'kaspa-mainnet, kaspa-testnet, kaspa-regtest, ' +
'bitcoin-mainnet, bitcoin-testnet, bitcoin-regtest, dogecoin-mainnet, ' +
'dogecoin-testnet, dogecoin-regtest, litecoin-mainnet, litecoin-testnet, litecoin-regtest';

// Split a "<fullname>-<network>" key (e.g. "bitcoin-mainnet") into a canonical
Expand All @@ -43,7 +44,12 @@ class CryptoNetworks {
static getBitcoinJsNetwork(networkName){
const p = parseNetworkName(networkName);
if(!p) throw new TypeError(`Unknown network: "${networkName}". Supported: ${SUPPORTED}`);
return coins.getCoinConfig(p.tick, p.net).net;
const cfg = coins.getCoinConfig(p.tick, p.net);
// Non-bitcoinjs families (Kaspa and friends) declare net: null in their coin file, so fail
// loudly here rather than handing a half-filled object to bitcoinjs - that turns into an
if(!cfg.net)
throw new TypeError(`Network "${networkName}" has no bitcoinjs network object: coin "${p.tick}" declares wireFormat "${cfg.wireFormat}". Consumers must branch on the wireFormat family.`);
return cfg.net;
}

// Uppercase native-coin ticker ('BTC'|'DOGE'|'LTC') for a network key. This is
Expand Down
Loading