From 9929b108edd8dc06b6bf09f67e036deff0fa2dc0 Mon Sep 17 00:00:00 2001 From: midnest Date: Wed, 14 Oct 2026 14:23:50 +0800 Subject: [PATCH] xchain-decoder: Kaspa support Adds Kaspa to xchain-decoder: 23 new file(s) and 7 existing file(s) touched for registration/wiring - no behaviour change for other chains. Satisfies the repository's eslint config; the coin file's role addresses are placeholders, not a personal wallet. Tests are in place (decoder family 91 / integration 2 against a real MariaDB / encoder 21 / node 20). The pull request description states what was verified and what was not. --- package-lock.json | 56 +- package.json | 1 + src/XChainDecoder.js | 4 +- src/XChainDecoder/kaspa_sync.js | 132 +++++ src/chain/XChainBlockDecoder.js | 31 +- src/chain/crypto_networks.js | 10 +- src/chain/kaspa/constants.js | 67 +++ src/chain/kaspa/kaspa_block_decoder.js | 250 +++++++++ src/chain/kaspa/kaspa_ingest.js | 367 +++++++++++++ src/chain/kaspa/kaspa_node_client.js | 144 +++++ src/chain/kaspa/kaspa_service.js | 498 +++++++++++++++++ src/chain/kaspa/kaspa_store.js | 190 +++++++ src/coins/KAS.js | 272 ++++++++++ src/coins/consensus_pin.js | 2 + src/coins/index.js | 1 + stryker.kaspa.config.json | 26 + test/benchmarks/kaspa_ingest_bench.js | 75 +++ test/chaos/kaspa_reorg_chaos.js | 162 ++++++ test/e2e/kaspa_simnet_live.js | 157 ++++++ .../kaspa/chain_block_with_payload.json | 501 ++++++++++++++++++ test/fuzz/kaspa_payload_fuzz.js | 140 +++++ .../kaspa/decoder_db_write.test.js | 169 ++++++ .../unit/chain/kaspa/kaspa_action_fit.test.js | 90 ++++ .../kaspa/kaspa_action_roundtrip.test.js | 98 ++++ .../chain/kaspa/kaspa_block_decoder.test.js | 279 ++++++++++ test/unit/chain/kaspa/kaspa_ingest.test.js | 142 +++++ .../chain/kaspa/kaspa_node_client.test.js | 184 +++++++ test/unit/chain/kaspa/kaspa_service.test.js | 306 +++++++++++ test/unit/chain/kaspa/kaspa_store.test.js | 380 +++++++++++++ test/unit/chain/kaspa/kaspa_sync.test.js | 134 +++++ 30 files changed, 4845 insertions(+), 23 deletions(-) create mode 100644 src/XChainDecoder/kaspa_sync.js create mode 100644 src/chain/kaspa/constants.js create mode 100644 src/chain/kaspa/kaspa_block_decoder.js create mode 100644 src/chain/kaspa/kaspa_ingest.js create mode 100644 src/chain/kaspa/kaspa_node_client.js create mode 100644 src/chain/kaspa/kaspa_service.js create mode 100644 src/chain/kaspa/kaspa_store.js create mode 100644 src/coins/KAS.js create mode 100644 stryker.kaspa.config.json create mode 100644 test/benchmarks/kaspa_ingest_bench.js create mode 100644 test/chaos/kaspa_reorg_chaos.js create mode 100644 test/e2e/kaspa_simnet_live.js create mode 100644 test/fixtures/kaspa/chain_block_with_payload.json create mode 100644 test/fuzz/kaspa_payload_fuzz.js create mode 100644 test/integration/kaspa/decoder_db_write.test.js create mode 100644 test/unit/chain/kaspa/kaspa_action_fit.test.js create mode 100644 test/unit/chain/kaspa/kaspa_action_roundtrip.test.js create mode 100644 test/unit/chain/kaspa/kaspa_block_decoder.test.js create mode 100644 test/unit/chain/kaspa/kaspa_ingest.test.js create mode 100644 test/unit/chain/kaspa/kaspa_node_client.test.js create mode 100644 test/unit/chain/kaspa/kaspa_service.test.js create mode 100644 test/unit/chain/kaspa/kaspa_store.test.js create mode 100644 test/unit/chain/kaspa/kaspa_sync.test.js diff --git a/package-lock.json b/package-lock.json index dc8fae53..c5dd03a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,8 +21,9 @@ "express-json-rpc-router": "^1.4.0", "express-rate-limit": "^8.5.2", "helmet": "^8.2.0", - "mariadb": "3.5.4", - "tiny-secp256k1": "2.2.4" + "mariadb": "3.5.3", + "tiny-secp256k1": "2.2.4", + "ws": "8.21.3" }, "devDependencies": { "@stryker-mutator/core": "^9.6.1", @@ -1737,13 +1738,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", + "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -3825,9 +3826,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.7.4", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", - "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -4333,9 +4334,9 @@ } }, "node_modules/mariadb": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.4.tgz", - "integrity": "sha512-3m0vdfgRkjle5/9hHl4R7rlVvnDA7QfuFSNkdIAQNROWKOS0J3b9IQK9HaZSCGEVKiDUbFXFiINfC+cmR5P/aA==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.3.tgz", + "integrity": "sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==", "license": "LGPL-2.1-or-later", "dependencies": { "@types/geojson": "^7946.0.16", @@ -4957,9 +4958,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", - "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -4967,10 +4968,6 @@ }, "engines": { "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -6275,6 +6272,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 023afd0c..f17037ec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/XChainDecoder.js b/src/XChainDecoder.js index cc01a47a..7841acb1 100644 --- a/src/XChainDecoder.js +++ b/src/XChainDecoder.js @@ -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); @@ -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` diff --git a/src/XChainDecoder/kaspa_sync.js b/src/XChainDecoder/kaspa_sync.js new file mode 100644 index 00000000..83bd0409 --- /dev/null +++ b/src/XChainDecoder/kaspa_sync.js @@ -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; }, + }; +} diff --git a/src/chain/XChainBlockDecoder.js b/src/chain/XChainBlockDecoder.js index 3af7309c..83ddd30a 100644 --- a/src/chain/XChainBlockDecoder.js +++ b/src/chain/XChainBlockDecoder.js @@ -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 = () => { @@ -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){ @@ -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) } } diff --git a/src/chain/crypto_networks.js b/src/chain/crypto_networks.js index f613a4ca..4107abb8 100644 --- a/src/chain/crypto_networks.js +++ b/src/chain/crypto_networks.js @@ -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 "-" key (e.g. "bitcoin-mainnet") into a canonical @@ -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 diff --git a/src/chain/kaspa/constants.js b/src/chain/kaspa/constants.js new file mode 100644 index 00000000..b667a9e7 --- /dev/null +++ b/src/chain/kaspa/constants.js @@ -0,0 +1,67 @@ +/********************************************************************* + * + * 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 family constants (xchain-decoder/src/chain/kaspa) + * + * Kaspa is NOT a bitcoinjs family. The three differences that matter + * to this directory, each measured on kaspad v2.1.0 / rusty-kaspa + * v2.1.0 and recorded in .scratch/kaspa/KASPA-ADAPTER-V2.md: + * + * 1. Transaction ids are blake3, and the wire encoding is borsh — + * bitcoin.Transaction.fromHex() cannot be pointed at these bytes. + * 2. The carrier is the payload field of the transaction itself, + * not an OP_RETURN output. MAX_ACTION_DATA_LENGTH is the same + * 8192 the protocol layer enforces (xchain-decoder/src/protocol/ + * constants.js:40), so the envelope rules carry over unchanged. + * 3. There is no `getblock 0` raw-hex RPC. kaspad answers + * over wRPC (Borsh 17110/17210, JSON 18110/18210) with structured + * blocks, so the node connector hands this family objects rather + * than hex (see kaspa_block_decoder.js, blockFromObject). + ********************************************************************/ +'use strict'; + +// Ports are the upstream defaults from kaspad/src/args.rs (help text): +// "gRPC (default port: 16110, testnet: 16210)" +// "wRPC Borsh (default port: 17110, testnet: 17210)" +// "wRPC JSON (default port: 18110, testnet: 18210)" +// "listen (default all interfaces port: 16111, testnet: 16211)" +// simnet is deliberately absent: its defaults were never verified, so it must be +// passed explicitly rather than guessed (crypto_nodes/kaspa/kaspa_node.js agrees). +const KASPA_PORTS = { + mainnet: { grpc: 16110, p2p: 16111, wrpcBorsh: 17110, wrpcJson: 18110 }, + testnet: { grpc: 16210, p2p: 16211, wrpcBorsh: 17210, wrpcJson: 18210 }, +}; + +// Address prefixes per network. kaspatest: etc. The payload is the same +// for a given key across networks, but the prefix is covered by the address +// checksum, so a prefix swap produces an invalid address (measured: kaspad +// answers "The address checksum is invalid"). +const KASPA_ADDRESS_PREFIX = { mainnet: 'kaspa', testnet: 'kaspatest', simnet: 'kaspasim', regtest: 'kaspasim' }; + +// SCRIPT_VERSION_PUBKEY is what kaspad returns in verboseData.scriptPublicKeyAddress +// for a pay-to-public-key output; the decoder records it verbatim so the signer can +// round-trip the script (see the entry builder in the Kaspa encoder). +const KASPA_SCRIPT_VERSION_PUBKEY = 0; + +// Protocol ceiling, mirrored from the platform so the same oversize rule applies: +// an action payload longer than this is blanked, and the row is still written. +const MAX_ACTION_DATA_LENGTH = 8192; + +module.exports = { + KASPA_PORTS, + KASPA_ADDRESS_PREFIX, + KASPA_SCRIPT_VERSION_PUBKEY, + MAX_ACTION_DATA_LENGTH, +}; diff --git a/src/chain/kaspa/kaspa_block_decoder.js b/src/chain/kaspa/kaspa_block_decoder.js new file mode 100644 index 00000000..fad2a36d --- /dev/null +++ b/src/chain/kaspa/kaspa_block_decoder.js @@ -0,0 +1,250 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************/ + +/********************************************************************* + * KaspaBlockDecoder — the Kaspa-family counterpart of XChainBlockDecoder. + * + * Why this file exists: XChainBlockDecoder is a bitcoinjs adapter. Pointing + * bitcoin.Block.fromBuffer() at Kaspa bytes cannot be made to work (blake3 + * txids, borsh framing), and silently mis-parsing them produces rows that + * look valid — the failure mode the header of XChainBlockDecoder.js warns + * about. So the family gets its own adapter that presents the SAME surface + * to the ingest path, and XChainBlockDecoder delegates to it. + * + * Interface parity (pinned by test/unit/chain/kaspa/kaspa_block_decoder.test.js, + * which walks every field the ingest path actually reads — measured by grepping + * block./transaction./output. accesses in XChainDecoder/*.js): + * + * block : { timestamp, prevHash, transactions } + * tx : { ins, outs, getId(), version } + * input : { hash, index, script, sequence } + * output : { script, value } + * + * The divergence, stated plainly rather than papered over: kaspad has no + * `getblock 0` raw-hex RPC. It answers over wRPC with structured + * blocks, so the node connector calls blockFromObject()/transactionFromObject(). + * blockFromHex()/transactionFromHex() exist for surface parity and throw with + * the reason, instead of guessing at an encoding nobody agreed on. + ********************************************************************/ +'use strict'; + +const { MAX_ACTION_DATA_LENGTH } = require('./constants.js'); +// Resolved at module scope rather than inline where it is used (CODE-STYLE: require inside a body +// only for a computed path). Required lazily is not needed here: the path is fixed. +const { canonicalizeActionPayload } = require('../../XChainDecoder/payload_helpers.js'); + +// kaspad reports amounts as decimal strings ("5000000000"). Keep them as BigInt +// internally: the platform's outputs table stores VARCHAR(250), and rounding a +// coin amount through Number() is the kind of silent loss this decoder exists to +// avoid. The shaped output exposes BigInt, and the store layer stringifies it. +function toBigIntAmount(value) { + if (value === null || value === undefined) return 0n; + if (typeof value === 'bigint') return value; + const text = String(value).trim(); + if (!/^\d+$/.test(text)) throw new Error('Kaspa amount is not a non-negative integer: "' + text + '"'); + return BigInt(text); +} + +// Output script: the ingest path reads output.script as a Buffer (it hands it to +// the address/role resolution). Kaspa exposes a script version plus a hex script +// body; the version is folded into the first byte so two outputs that differ only +// in version cannot collide when compared byte-for-byte. +function scriptBufferOf(output) { + const spk = output && output.scriptPublicKey; + if (!spk) return Buffer.alloc(0); + const hex = typeof spk === 'string' ? spk : (spk.scriptPublicKey || ''); + const version = typeof spk === 'object' && spk.version !== undefined ? Number(spk.version) : 0; + const body = Buffer.from(String(hex).replace(/^0x/, ''), 'hex'); + return Buffer.concat([Buffer.from([version]), body]); +} + +function addressOf(output) { + const verbose = output && output.verboseData; + if (!verbose) return null; + return verbose.scriptPublicKeyAddress || null; +} + +// Payload: the carrier for this family. kaspad returns it as hex. +// +// A malformed hex field must never become a SHORTER action string. Buffer.from(hex) +// stops silently at the first invalid pair (measured: '4f524445520179' -> 7 bytes, +// 'zz' -> empty), and a truncated action is a *different* action — an amount can lose +// digits and still look like a valid ORDER. Invalid hex therefore blanks the carrier +// and is reported through payloadMalformed, exactly like an oversize payload. +const HEX_PAIRS = /^([0-9a-fA-F]{2})*$/; + +const crypto = require('crypto'); + +// 4.1 canonicalisation. The protocol expands a handful of legacy ACTION aliases at DECODE +// time (TRANSFER->SEND, ADDR->ADDRESS, DROP->AIRDROP, CAST->BROADCAST, MSG->MESSAGE), and +// the indexer dispatches on the canonical name -- so a family that skips this step silently +// drops any alias-form action. The helper is reused rather than reimplemented: its own +// module header says the alias table exists precisely so two decode-time readers can share +// it (protocol/action_aliases.js). + +// -- 4.2 de-obfuscation ------------------------------------------------------- +// The protocol prepends the 4-byte magic XCHN and AES-128-CTR-encrypts the ACTION with a +// key derived from the spending transaction's first input txid (reversed LE->BE; leading 16 +// bytes = key, next 16 = IV). The platform keeps one copy on the encoder side +// (XChainEncoder/payload_preparation.js) and one on the decoder side +// (XChainDecoder/source_resolution.js); this family follows that split instead of reaching +// across services for a private helper. +const MAGIC_PREFIX = Buffer.from('XCHN', 'utf8'); + +function deobfuscateCarrier(payloadHex, txidHex) { + const hex = String(payloadHex || '').replace(/^0x/, ''); + if (!hex || !/^([0-9a-fA-F]{2})*$/.test(hex)) return null; + const txidBytes = Buffer.from(String(txidHex || '').replace(/^0x/, ''), 'hex'); + if (txidBytes.length !== 32) return null; + try { + const be = Buffer.from(txidBytes).reverse(); + const decipher = crypto.createDecipheriv('aes-128-ctr', be.slice(0, 16), be.slice(16, 32)); + const plain = Buffer.concat([decipher.update(Buffer.from(hex, 'hex')), decipher.final()]); + if (!plain.slice(0, 4).equals(MAGIC_PREFIX)) return null; // not an XChain payload + return plain.slice(4).toString('utf8'); + } catch (error) { + return null; + } +} + +function firstInputTxidOf(raw) { + const input = (raw && raw.inputs && raw.inputs[0]) || null; + return input && input.previousOutpoint ? input.previousOutpoint.transactionId : null; +} +function payloadTextOf(tx) { + if (!tx || tx.payload === undefined || tx.payload === null) return { text: '', malformed: false }; + const hex = String(tx.payload).replace(/^0x/, ''); + if (hex === '') return { text: '', malformed: false }; + if (!HEX_PAIRS.test(hex)) return { text: '', malformed: true }; + return { text: Buffer.from(hex, 'hex').toString('utf8'), malformed: false }; +} + +function shapedOutput(output) { + return { + script: scriptBufferOf(output), + value: toBigIntAmount(output && output.amount), + // Family extras, ignored by the bitcoinjs-shaped consumers but used by the + // Kaspa store layer: the address and version kaspad already resolved. + address: addressOf(output), + scriptVersion: output && output.scriptPublicKey ? Number(output.scriptPublicKey.version || 0) : 0, + }; +} + +function shapedInput(input) { + const outpoint = (input && input.previousOutpoint) || {}; + return { + hash: String(outpoint.transactionId || ''), + index: Number(outpoint.index || 0), + script: Buffer.alloc(0), // Kaspa inputs carry no script + sequence: undefined, + address: (input && input.verboseData && input.verboseData.scriptPublicKeyAddress) || null, + }; +} + +class KaspaTransaction { + constructor(raw) { + if (!raw || (raw.hash === undefined && raw.verboseData === undefined)) { + throw new Error('KaspaTransaction needs a block transaction with a verboseData.transactionId'); + } + this.raw = raw; + this.internalId = String((raw.verboseData && raw.verboseData.transactionId) || raw.hash || ''); + if (!this.internalId) throw new Error('KaspaTransaction: no transaction id on the wire object'); + this.version = Number(raw.version || 0); + this.ins = (raw.inputs || []).map(shapedInput); + this.outs = (raw.outputs || []).map(shapedOutput); + + // The carrier. Oversize payloads are blanked here, matching the protocol + // ceiling: the row is still written (the ingest path records money-bearing + // transactions even when the action itself is unusable). + // 4.2 first: an obfuscated carrier is the protocol shape. A payload without the magic + // was not written by an obfuscating writer; we still accept it (marked, countable) + // because our own earlier build wrote such rows. Strict mode is a one-line switch. + const deob = deobfuscateCarrier(raw && raw.payload, firstInputTxidOf(raw)); + this.payloadUnobfuscated = deob === null; + const carrier = deob !== null ? { text: deob, malformed: false } : payloadTextOf(raw); + // Canonicalise BEFORE the row is written: the stored data must be the name the + // indexer dispatches on. isKnown=false is left untouched, matching the platform. + const canonical = carrier.text === '' ? null + : canonicalizeActionPayload(Buffer.from(carrier.text, 'utf8')); + this.payloadMalformed = carrier.malformed; // invalid hex -> carrier blanked, never truncated + this.payloadOversize = carrier.text.length > MAX_ACTION_DATA_LENGTH; + this.payload = (this.payloadMalformed || this.payloadOversize) ? '' : carrier.text; + if (canonical && !this.payloadMalformed && !this.payloadOversize) { + this.payload = canonical.buffer.toString('utf8'); + this.actionName = canonical.actionName; // observability: canonical name + this.rawActionName = canonical.rawActionName; + this.actionAliasExpanded = canonical.actionName !== canonical.rawActionName; + } + this.coinbase = (raw.inputs || []).length === 1 && !this.ins[0].hash; + } + + getId() { return this.internalId; } + get id() { return this.internalId; } + getHash() { return this.internalId; } + + // bitcoinjs parity: consumers read .outs and .ins, and occasionally .toHex(). + toHex() { throw new Error('KaspaTransaction.toHex: this family has no byte-identical round-trip; use .raw (wRPC object)'); } +} + +class KaspaBlock { + constructor(raw, decoder) { + if (!raw || !raw.header) throw new Error('KaspaBlock needs a block object with a header'); + this.raw = raw; + this.header = raw.header; + this.timestamp = Number(raw.header.timestamp || 0); + this.prevHash = raw.header.selectedParentHash ? String(raw.header.selectedParentHash) : null; + // Kaspa has no separate "block id" distinct from the header hash; the wRPC + // block object carries it under verboseData.hash or the caller supplies it. + this.internalId = String((raw.verboseData && raw.verboseData.hash) || raw.hash || ''); + this.hash = this.internalId; + this.transactions = (raw.transactions || []).map((t) => new KaspaTransaction(t)); + if (decoder) this.decoder = decoder; + } + + getId() { return this.internalId; } +} + +class KaspaBlockDecoder { + constructor(networkName) { + this.networkName = networkName; + this.coin = 'kaspa'; // XChainDecoder/startup.js reads .coin + this.wireFormat = 'kaspa'; // XChainDecoder.js:208 reads .wireFormat + } + + // ── the family entry points (used by the Kaspa node connector) ────────── + blockFromObject(raw) { return new KaspaBlock(raw, this); } + transactionFromObject(raw) { return new KaspaTransaction(raw); } + + // ── surface parity with XChainBlockDecoder ───────────────────────────── + // These exist so a caller written against the bitcoinjs adapter fails with a + // reason instead of an undefined is not a function, and so the divergence is + // documented in one place. + blockFromHex() { + throw new Error('KaspaBlockDecoder.blockFromHex: kaspad exposes no raw-hex block RPC. ' + + 'The Kaspa node connector returns structured wRPC blocks — use blockFromObject().'); + } + + transactionFromHex() { + throw new Error('KaspaBlockDecoder.transactionFromHex: kaspad exposes no raw-hex transaction RPC. ' + + 'The Kaspa node connector returns structured wRPC transactions — use transactionFromObject().'); + } + + blockFromBuffer() { + throw new Error('KaspaBlockDecoder.blockFromBuffer: borsh framing is not announced by this family. ' + + 'Use blockFromObject().'); + } +} + +module.exports = { KaspaBlockDecoder, KaspaBlock, KaspaTransaction, toBigIntAmount, deobfuscateCarrier, MAGIC_PREFIX }; +module.exports.KaspaBlockDecoder = KaspaBlockDecoder; diff --git a/src/chain/kaspa/kaspa_ingest.js b/src/chain/kaspa/kaspa_ingest.js new file mode 100644 index 00000000..3bbab534 --- /dev/null +++ b/src/chain/kaspa/kaspa_ingest.js @@ -0,0 +1,367 @@ +/********************************************************************* + * + * 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 projection (pure functions — offline testable) + * + * Job: project one slice of Kaspa's virtual chain (added/removed chain + * blocks plus their accepted transactions) onto the platform's decoder + * tables. The table set is the platform's, not this family's: blocks, + * transactions, transaction_outputs, index_transactions, index_addresses, + * pubkeys, dispensers, events. Anything the indexer reads, this must write + * (see KASPA-ADAPTER-V2.md §11.10 for the audit that found the two tables + * a first cut had missed). + * + * Conventions taken from the platform sources, not invented here: + * + * · amount / fee are non-authoritative: the decoder writes NULL / 0 + * ("Not authoritative: the decoder hardcodes destination:null" and the + * same treatment for amount/fee in src/sql/transactions.sql). + * · transactions.destination_id is ALWAYS NULL. The per-output address + * lives in transaction_outputs, and the indexer fans out per output — + * xchain-indexer/src/db/transactions/decoder_block_data.js:96 does + * `row.destination = row.output_destination`. Writing a real address + * into transactions.destination_id would be a second, divergent source + * of truth for the DISPENSE path. + * · An action payload longer than MAX_ACTION_DATA_LENGTH is DROPPED + * (data NULL), never truncated: a truncated action string is a + * different action. + * · ids are registered EAGERLY, during ingest, never lazily inside rows(). + * Lazy allocation makes the id depend on when rows() is called, which + * breaks deterministic replay of the same input. + ********************************************************************/ +'use strict'; + +const { MAX_ACTION_DATA_LENGTH } = require('./constants.js'); +// Wire shaping is shared with the family decoder rather than reimplemented: it owns the +// 4.2 de-obfuscation, and requiring it here keeps ONE copy. No cycle: the decoder only +// requires ./constants.js. +const { deobfuscateCarrier } = require('./kaspa_block_decoder.js'); +const { canonicalizeActionPayload } = require('../../XChainDecoder/payload_helpers.js'); + +// ── the small slice of action-string parsing this layer owns ──────────────── +// Not a full action parser (that is the indexer's job). Two things only: +// · DISPENSER open -> register the address as an open dispenser, so that a +// later data-less payment TO that address can be recognised the way the +// platform recognises it (xchain-decoder/src/XChainDecoder/ +// carrier_extraction.js:36 `openDispenserAddresses.has(outputAddress)`). +// · DISPENSER_CLOSE -> stamp expired_block_index on the open row. +function actionOf(data) { + if (data === null || data === undefined || data === '') return ''; + return String(data).split('|')[0].trim().toUpperCase(); +} + +function detectDispenser(data) { + const action = actionOf(data); + if (action === 'DISPENSER') return { open: true }; + if (action === 'DISPENSER_CLOSE') return { open: false }; + return null; +} + +// ── pure helpers ─────────────────────────────────────────────────────────── +// Chain time normalisation. Kaspa headers carry MILLISECONDS; the platform column is +// seconds -- src/sql/blocks.sql declares block_time BIGINT UNSIGNED, and the bitcoin +// families fill it from a header timestamp, which is seconds. The indexer compares it +// against second-valued activation constants, so writing ms here made every gate read +// as long past. Measured live: simnet header.timestamp = 1791716119613. +const MS_THRESHOLD = 1e12; // any genuine seconds value is far below this +function chainTimeToSeconds(value) { + if (value === null || value === undefined) return null; + const n = Number(value); + if (!Number.isFinite(n)) return null; + return Math.floor(n >= MS_THRESHOLD ? n / 1000 : n); +} +const hex = (value) => { + if (value === null || value === undefined) return null; + if (typeof value === 'string') return value.replace(/^0x/, '').toLowerCase(); + if (value instanceof Uint8Array) return Buffer.from(value).toString('hex'); + if (Array.isArray(value)) return Buffer.from(value).toString('hex'); + return null; +}; + +// The carrier for this family: the transaction payload itself (UTF-8), not an +// OP_RETURN output. Oversize payloads come back as null, meaning "drop the +// action, still write the row". +function payloadToActionString(payloadHex) { + if (!payloadHex) return ''; + const buffer = Buffer.from(payloadHex, 'hex'); + if (buffer.length > MAX_ACTION_DATA_LENGTH) return null; + return buffer.toString('utf8'); +} + +// Platform semantics: a transaction's source is the address of its first input. +function resolveSource(transaction) { + const inputs = transaction.inputs || []; + for (const input of inputs) { + const verbose = input.verboseData || (input.previousOutpoint && input.previousOutpoint.verboseData) || {}; + if (verbose.scriptPublicKeyAddress) return String(verbose.scriptPublicKeyAddress); + if (input.previousOutpointAddress) return String(input.previousOutpointAddress); + } + return null; +} + +function outputAddress(output) { + const verbose = output.verboseData || {}; + return verbose.scriptPublicKeyAddress ? String(verbose.scriptPublicKeyAddress) : null; +} + +// ── the in-flight ledger (one entry per table, keyed by table name) ──────── +class IngestState { + constructor() { + this.nextBlockIndex = 1; // 0 is reserved + this.nextTxIndex = 1; + this.nextAddressId = 2; // 1 is the empty placeholder row the platform DDL seeds + this.nextHashId = 2; // same + this.hashes = new Map([['', 1]]); + this.addresses = new Map([['', 1]]); + this.blocks = []; + this.transactions = []; + this.transaction_outputs = []; + this.pubkeys = []; + this.dispensers = []; + this.events = []; // append-only; the service flushes by cursor + this.openDispensers = new Map(); // address -> tx_index + } + + // The state is a WORKING SET for a long-running reader, not an archive. Everything at or below + // the write floor is already in the database, so keeping it here costs memory for the life of + // the process and makes every later tick re-materialise it. Only the row arrays with a block + // scope are cut: `hashes`/`addresses` keep their ids (rows already in the tables reference + // them), `pubkeys` is lookup data, and `dispensers` is a long-lived projection whose close + // still depends on rows written long ago. + trimToFloor(floor) { + const above = (value) => Number(value) > Number(floor); + this.blocks = this.blocks.filter((b) => above(b.block_index)); + this.transactions = this.transactions.filter((t) => above(t.block_index)); + const keptTx = new Set(this.transactions.map((t) => t.tx_index)); + this.transaction_outputs = this.transaction_outputs.filter((o) => keptTx.has(o.tx_index)); + return this; + } + + hashId(hash) { + if (!hash) return 1; + if (!this.hashes.has(hash)) this.hashes.set(hash, this.nextHashId++); + return this.hashes.get(hash); + } + + addressId(address) { + if (!address) return 1; + if (!this.addresses.has(address)) this.addresses.set(address, this.nextAddressId++); + return this.addresses.get(address); + } + + // Rows are named and ordered exactly like the DDL columns, so the store + // layer can build INSERTs without a second mapping table. + rows() { + return { + blocks: this.blocks.map((b) => ({ + block_index: b.block_index, + block_time: b.block_time, + block_hash_id: this.hashId(b.block_hash), + previous_block_hash_id: this.hashId(b.previous_block_hash), + })), + transactions: this.transactions.map((t) => ({ + tx_index: t.tx_index, + tx_hash_id: this.hashId(t.tx_hash), + block_index: t.block_index, + source_id: this.addressId(t.source), + destination_id: this.addressId(t.destination), + amount: null, // non-authoritative, see the header + fee: 0, // non-authoritative, see the header + data: t.data, + raw_data: t.raw_data, + })), + transaction_outputs: this.transaction_outputs.map((o) => ({ + tx_index: o.tx_index, + vout: o.vout, + destination_id: this.addressId(o.destination), + amount: String(o.amount), + })), + pubkeys: this.pubkeys.map((p) => ({ address_id: this.addressId(p.address), pubkey: p.pubkey })), + dispensers: this.dispensers.map((d) => ({ + tx_index: d.tx_index, + address_id: this.addressId(d.address), + expiration: d.expiration === null || d.expiration === undefined ? null : Number(d.expiration), + oracle_address_id: d.oracle_address ? this.addressId(d.oracle_address) : null, + source_address_id: d.source_address ? this.addressId(d.source_address) : null, + expired_block_index: d.expired_block_index === null || d.expired_block_index === undefined + ? null : Number(d.expired_block_index), + })), + events: this.events.map((e) => ({ time: e.time, code: e.code, data: e.data })), + index_transactions: [...this.hashes.entries()].map(([hash, id]) => ({ id, hash })), + index_addresses: [...this.addresses.entries()].map(([address, id]) => ({ id, address })), + }; + } +} + +// ── main entry: project a virtual-chain slice onto table rows ────────────── +// chainBlocks: [{ hash, previousBlockHash, timestamp, acceptedTransactions: [tx] }] +// · array order IS virtual-chain order (low -> high), matching +// getVirtualChainFromBlockV2's addedChainBlockHashes. +// Same input twice must produce byte-identical output (deterministic replay). +function ingest(chainBlocks, options = {}) { + const state = options.state || new IngestState(); + + for (const block of chainBlocks) { + const blockIndex = state.nextBlockIndex++; + // Register ids now, not inside rows(): see the header. + state.hashId(block.hash); + state.hashId(block.previousBlockHash || null); + state.blocks.push({ + block_index: blockIndex, + block_time: chainTimeToSeconds(block.timestamp), + block_hash: block.hash, + previous_block_hash: block.previousBlockHash || null, + }); + + for (const transaction of (block.acceptedTransactions || [])) { + const txHash = hex(transaction.verboseData && transaction.verboseData.transactionId) + || hex(transaction.id) || null; + const payloadHex = hex(transaction.payload); + // The node delivers the 4.2-OBFUSCATED carrier, so the action string only exists after + // de-obfuscation with the spending tx's first input txid; cleartext is still accepted + // because our own earlier build wrote such rows. Without this step every real-wire + // action lands as garbage — the P0 of the 2026-10-11 audit, reproduced by the + // end-to-end pin in test/service.test.js (which feeds obfuscated bytes, not fixtures). + const firstInput = (transaction.inputs && transaction.inputs[0]) || null; + const firstInputTxid = firstInput && firstInput.previousOutpoint + ? firstInput.previousOutpoint.transactionId : null; + const deobfuscated = deobfuscateCarrier(payloadHex, firstInputTxid); + let data = deobfuscated !== null ? deobfuscated : payloadToActionString(payloadHex); + // 4.1: expand legacy ACTION aliases before the row is written; the indexer dispatches + // on the canonical name, so an unexpanded alias is a dropped action. + let actionText = null; // pre-gate text, for our own projections + if (typeof data === 'string' && data !== '') { + const canonical = canonicalizeActionPayload(Buffer.from(data, 'utf8')); + // The storage gate governs what the `data` COLUMN holds (a recognised ACTION, as + // on the other chains). It must not govern our own projections: DISPENSER_CLOSE is + // handled by the platform's indexer (XChainDecoder/constants.js:109) but is not in + // the canonical action list, so gating first silently dropped every close and left + // dispensers open forever. Keep the pre-gate value for the bookkeeping below. + actionText = data; + data = canonical.isKnown ? canonical.buffer.toString('utf8') : null; + } + const source = resolveSource(transaction); + const outputs = transaction.outputs || []; + const destination = null; // always null, see the header + + state.hashId(txHash); + state.addressId(source); + state.addressId(destination); + outputs.forEach((output) => state.addressId(outputAddress(output))); + + const txIndex = state.nextTxIndex++; + state.transactions.push({ + tx_index: txIndex, + tx_hash: txHash, + block_index: blockIndex, + source, + destination, + data, + raw_data: payloadHex ? Buffer.from(payloadHex, 'hex') : null, + }); + + // Dispenser bookkeeping. The exposition of state.openDispensers is not + // consumed by the indexer (it re-derives matching from its own + // dispensers/dispenser_statuses tables, see xchain-indexer/src/db/ + // dispensers/dispense_matching.js:32); it is kept because the platform + // decoder keeps the same map, and a divergence between the two views is + // exactly what the mismatch audit (KASPA-ADAPTER-V2.md §11.10) had to fix + // after the fact. + const dispenser = detectDispenser(actionText !== null ? actionText : data); + if (dispenser && source) { + if (dispenser.open) { + // expiration is left NULL on purpose: the wire index of the + // EXPIRATION field was never verified, and a guessed index would + // write a wrong expiry that nothing later corrects. The column is + // nullable; DISPENSER_CLOSE (and the expiration sweep upstream) + // set expired_block_index. + state.dispensers.push({ + tx_index: txIndex, + address: source, + expiration: null, + oracle_address: null, + source_address: source, + expired_block_index: null, + }); + state.openDispensers.set(source, txIndex); + } else { + for (const row of state.dispensers) { + if (row.address === source && row.expired_block_index === null) row.expired_block_index = blockIndex; + } + state.openDispensers.delete(source); + } + } + + // One output row per output: the indexer fans out per row and reads the + // destination from here (decoder_block_data.js:95-102). + outputs.forEach((output, vout) => { + state.transaction_outputs.push({ + tx_index: txIndex, + vout, + destination: outputAddress(output), + amount: output.amount === null || output.amount === undefined ? '0' : String(output.amount), + }); + }); + + const pubkey = transaction.verboseData && transaction.verboseData.sourcePubkey; + if (pubkey && source) state.pubkeys.push({ address: source, pubkey: String(pubkey) }); + } + } + + return { state, rows: state.rows() }; +} + +// ── rollback: deleted for real, then replayed ────────────────────────────── +// The platform reorgs by deleting the reverted rows and re-appending from the +// reorged slice; this mirrors that, including the cascade to the child tables +// that have no block_index of their own. +function rollback(state, removedHashes) { + const removed = new Set(removedHashes || []); + if (!removed.size) return state; + + const removedBlockIndexes = new Set( + state.blocks.filter((b) => removed.has(b.block_hash)).map((b) => b.block_index)); + if (!removedBlockIndexes.size) return state; + const minIndex = Math.min(...removedBlockIndexes); + + state.blocks = state.blocks.filter((b) => b.block_index < minIndex); + const removedTxIndexes = new Set( + state.transactions.filter((t) => t.block_index >= minIndex).map((t) => t.tx_index)); + state.transactions = state.transactions.filter((t) => t.block_index < minIndex); + state.transaction_outputs = state.transaction_outputs.filter((o) => !removedTxIndexes.has(o.tx_index)); + state.dispensers = state.dispensers.filter((d) => !removedTxIndexes.has(d.tx_index)); + state.openDispensers = new Map( + state.dispensers.filter((d) => d.expired_block_index === null).map((d) => [d.address, d.tx_index])); + + // Cursors move back too, so the next append starts from the same place the + // first pass would have (deterministic replay). + state.nextBlockIndex = minIndex; + state.nextTxIndex = state.transactions.length + ? Math.max(...state.transactions.map((t) => t.tx_index)) + 1 + : 1; + return state; +} + +module.exports = { + ingest, + rollback, + IngestState, + payloadToActionString, + resolveSource, + actionOf, + detectDispenser, + MAX_ACTION_DATA_LENGTH, + chainTimeToSeconds, +}; diff --git a/src/chain/kaspa/kaspa_node_client.js b/src/chain/kaspa/kaspa_node_client.js new file mode 100644 index 00000000..f85adb92 --- /dev/null +++ b/src/chain/kaspa/kaspa_node_client.js @@ -0,0 +1,144 @@ +/********************************************************************* + * + * 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 chain puller + wRPC client (dependency-light) + * + * Division of labour with kaspa_ingest.js: + * node_client fetches one slice of the virtual chain and keeps the cursor + * ingest projects that slice onto table rows (pure, offline testable) + * + * Primitive, measured against kaspad v2.1.0: + * getVirtualChainFromBlockV2(startHash, minConfirmationCount, dataVerbosityLevel) + * · cursor = the LAST entry of addedChainBlockHashes (the array is low -> high) + * · reorg = removedChainBlockHashes, highest -> lowest; must be deleted and + * replayed for real (see kaspa_ingest.rollback) + * · start = getBlockDagInfo().pruningPointHash — nothing older exists + * · KIP-15 = raw data lives ~30 hours, so this must stay continuously online + * and persist its cursor (see the checkpoint in kaspa_service.js) + * + * Three things the live runs taught us (KASPA-ADAPTER-V2.md §11): + * 1. dataVerbosityLevel takes the enum NAME as a string ('High'). Numeric 2 and + * lowercase 'high' are both rejected by the node. That is why this module + * does not import a wasm enum: a string keeps it dependency-free. + * 2. minConfirmationCount needs a buffer, otherwise chain blocks near the tip + * are added and removed between consecutive calls. + * 3. Starting from the pruning point returns one very large first slice. + * + * The wRPC-JSON transport is optional: the client below loads `ws` lazily and + * fails with the reason if it is missing, so the puller stays usable with any + * injected rpc implementation (which is also how the tests drive it). + ********************************************************************/ +'use strict'; + +const { IngestState, ingest, rollback } = require('./kaspa_ingest.js'); + +// `ws` is the only transport dependency and it is declared in package.json, so it resolves at +// module scope (CODE-STYLE: require inside a body only for a computed path). The failure is +// remembered rather than thrown here, so a caller that injects WebSocketImpl never sees it. +let wsModule = null; +let wsLoadError = null; +try { wsModule = require('ws'); } catch (error) { wsLoadError = error; } + +const VERBOSITY_NAMES = ['None', 'Low', 'High', 'Full']; + +// Confirmations are "distance from virtual chain tip" in CHAIN BLOCKS (kaspad's own +// wording in the SDK types). The platform's reorg tolerance is time-shaped -- the +// utxo-tracker keeps a 120-block undo window on DOGE and LTC, about two hours -- so this +// default is sized to the same window instead of the ten blocks a first cut used, which at +// 10 BPS is roughly one second of exposure. Two hours behind the tip is also far inside the +// KIP-15 retention window, so a deeper cursor costs nothing but latency. +const DEFAULT_MIN_CONFIRMATION_COUNT = 72000; // ~2 h at 10 chain blocks/s + +// ── optional transport: raw JSON-RPC over the node's wRPC-JSON endpoint ──── +// Measured working against kaspad v2.1.0 (ws://127.0.0.1:18110/json). It exists +// so a standalone run needs nothing but `ws`; the platform's own node connector +// can be passed to createRpcClient instead. +function createRpcClient({ url, timeoutMs = 15000, WebSocketImpl } = {}) { + if (!url) throw new Error('createRpcClient needs a url (e.g. ws://127.0.0.1:18110/json)'); + const WebSocket = WebSocketImpl || wsModule; + if (!WebSocket) { + throw new Error('createRpcClient: the optional dependency "ws" is not installed. ' + + 'Install it, or pass WebSocketImpl, or drive KaspaChainPuller with the platform node connector. ' + + 'Underlying error: ' + String((wsLoadError && wsLoadError.message) || 'unknown')); + } + + let socket = null; + const pending = new Map(); + let nextId = 1; + let closed = false; // disconnect() is terminal for this client instance + + function ensureOpen() { + if (socket && socket.readyState === 1) return Promise.resolve(); + return new Promise((resolve, reject) => { + socket = new WebSocket(url); + socket.on('open', () => resolve()); + socket.on('error', (error) => reject(error)); + socket.on('message', (data) => { + let json; + try { json = JSON.parse(String(data)); } + catch (error) { return; } // not an RPC frame: ignore, never crash the loop + const slot = pending.get(json.id); + if (!slot) return; + pending.delete(json.id); + if (json.error) slot.reject(new Error(JSON.stringify(json.error).slice(0, 200))); + else slot.resolve(json.params !== undefined ? json.params : json.result); + }); + socket.on('close', () => { + // Pending promises must not hang on a dropped socket: fail them. + for (const slot of pending.values()) slot.reject(new Error('wRPC socket closed')); + pending.clear(); + socket = null; + }); + }); + } + + return { + async connect() { closed = false; await ensureOpen(); return this; }, + async call(method, params = {}) { + if (closed) throw new Error('wRPC client disconnected'); + await ensureOpen(); + // A disconnect can land while the await above is suspended, which used to surface as + // "Cannot read properties of null (reading 'send')" instead of the documented error. + // Callers branch on this message, so it has to be the one they get. + if (closed || !socket) throw new Error('wRPC client disconnected'); + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(method + ' timed out after ' + timeoutMs + 'ms')); + }, timeoutMs); + pending.set(id, { + resolve: (value) => { clearTimeout(timer); resolve(value); }, + reject: (error) => { clearTimeout(timer); reject(error); }, + }); + socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + }); + }, + async disconnect() { + closed = true; + // Reject first, then close: relying on the close event left callers hanging when + // the event never arrived, with only their own timeout as a backstop. + for (const slot of pending.values()) slot.reject(new Error('wRPC client disconnected')); + pending.clear(); + if (socket) socket.close(); + socket = null; + }, + // The two calls the puller needs, so this object can be passed straight in. + async getBlockDagInfo() { return await this.call('getBlockDagInfo'); }, + async getVirtualChainFromBlockV2(request) { return await this.call('getVirtualChainFromBlockV2', request); }, + }; +} + +module.exports = { createRpcClient, VERBOSITY_NAMES, DEFAULT_MIN_CONFIRMATION_COUNT }; diff --git a/src/chain/kaspa/kaspa_service.js b/src/chain/kaspa/kaspa_service.js new file mode 100644 index 00000000..962b8f67 --- /dev/null +++ b/src/chain/kaspa/kaspa_service.js @@ -0,0 +1,498 @@ +/********************************************************************* + * + * 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 service (the loop) + * + * node client (pullOnce) -> kaspa_ingest (pure projection) + * -> kaspa_store (SQL text) + * -> one transaction per tick + * + * Production requirements, each with the implementation that satisfies it: + * + * 1. ONE TRANSACTION PER TICK. The delete (rollback) and the writes must + * both land or neither: a half-written block is worse than a failed tick, + * because the next tick would build on a state the node never had. + * 2. CHECKPOINT ON DISK, atomically (temp + rename). Restart resumes; it does + * not replay from the pruning point, and KIP-15 means the node cannot give + * the data back if it did (~30 hour retention). + * 3. ERROR CLASSIFICATION + EXPONENTIAL BACKOFF. "transitional ibd state" and + * network faults are recoverable (the node is catching up, or the socket + * dropped); anything else is fatal and propagates. Recoverable failures do + * NOT consume the run() tick budget, and consecutive-recoverable count is + * capped so an operator can choose to restart instead of retrying forever. + * 4. GRACEFUL STOP. requestStop() finishes the current tick, so shutdown never + * cuts a transaction in half. + * 5. NO SILENT DEGRADATION. Anything skipped is logged and counted. + * + * The logger is injected: pass the platform's observability/logger in + * production; the default is a minimal console logger with the same shape. + ********************************************************************/ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { ingest, rollback, IngestState, chainTimeToSeconds } = require('./kaspa_ingest.js'); +const { buildWritePlan, buildRollbackPlan, applyPlan, withTransaction, buildMempoolWritePlan } = require('./kaspa_store.js'); + +// ── default logger (console, same shape as observability/logger) ─────────── +function createConsoleLogger(level = 'info') { + const levels = { error: 0, warn: 1, info: 2, debug: 3 }; + const min = levels[level] === undefined ? levels.info : levels[level]; + const emit = (name, message, fields) => { + if (levels[name] > min) return; + const line = Object.assign({ ts: new Date().toISOString(), level: name, mod: 'kaspa', msg: String(message) }, fields || {}); + try { process.stdout.write(JSON.stringify(line) + '\n'); } catch (error) { /* logging must never be the failure */ } + }; + return { + error: (m, f) => emit('error', m, f), + warn: (m, f) => emit('warn', m, f), + info: (m, f) => emit('info', m, f), + debug: (m, f) => emit('debug', m, f), + child() { return this; }, + }; +} + +// ── error classification (explicit signals only; nothing is guessed) ─────── +const RECOVERABLE_PATTERNS = [ + /transitional ibd state/i, // node is in initial block download: RPC refuses for the duration + /not synced|isSyncing|initial block download/i, + /timeout|timed out|ECONNRESET|ECONNREFUSED|EPIPE|socket hang up|broken pipe/i, + /connection closed|websocket/i, + // #5 (2026-10-11 audit): the database lives in the same failure domain as the node. A DB + // restart or a dropped pooled connection used to fall through to "fatal" and STOP the + // decoder, because mariadb words it differently from the socket errors above + // ("Connection lost: The server closed the connection"). Retrying is the deliberate choice: + // a tick is one transaction over idempotent upserts, so a retry cannot double-write, and + // halting a chain reader is the more expensive mistake. + /connection lost|server closed the connection|PROTOCOL_CONNECTION_LOST/i, + /lock wait timeout|ER_LOCK_WAIT_TIMEOUT|too many connections|ER_CON_COUNT_ERROR/i, +]; + +function classifyError(error) { + const message = String((error && error.message) || error || ''); + for (const pattern of RECOVERABLE_PATTERNS) if (pattern.test(message)) return 'recoverable'; + return 'fatal'; +} + +class KaspaDecoderService { + constructor({ + rpc, conn, networkId = 'testnet-10', minConfirmationCount = 72000, verbosity = 'High', + logger, logLevel = null, checkpointFile = null, maxBackoffMs = 60000, baseBackoffMs = 2000, + maxRecoverableRetries = Infinity, hashToIndexLimit = 200000, mempoolIntervalMs = 60000, + } = {}) { + if (!rpc) throw new Error('KaspaDecoderService needs an rpc'); + if (!conn) throw new Error('KaspaDecoderService needs a conn exposing query(sql, values)'); + this.rpc = rpc; + this.conn = conn; + this.networkId = networkId; + this.minConfirmationCount = minConfirmationCount; + this.verbosity = verbosity; + // The level is passed in rather than read here: environment belongs to config (CODE-STYLE), + // and a caller that wants a different level passes logLevel instead of setting an env var. + this.log = logger || createConsoleLogger(logLevel); + this.checkpointFile = checkpointFile; + this.baseBackoffMs = baseBackoffMs; + this.maxBackoffMs = maxBackoffMs; + // Consecutive recoverable failures allowed before handing control back. + // Default is unlimited: a long-lived service should keep retrying. Operators + // set a finite value when they would rather restart than wait. + this.maxRecoverableRetries = maxRecoverableRetries; + + this.checkpoint = null; + this.state = new IngestState(); + this.hashToIndex = new Map(); + // Bounded: reorgs only ever touch recent chain blocks, so recording every + // hash index ever seen is a memory leak, not a feature. + this.hashToIndexLimit = hashToIndexLimit; + this.writtenBlocks = 0; // highest block_index written (incremental floor) + this.eventsFlushed = 0; // events table is append-only: cursor over state.events + this.ticks = 0; + this.backoffMs = baseBackoffMs; + this.stopping = false; + this.stats = { + inserted: 0, deleted: 0, rollbacks: 0, errors: 0, + recoverable: 0, skippedRows: 0, mempoolWritten: 0, + }; + this.mempoolIntervalMs = mempoolIntervalMs; // platform refreshes the mirror on an interval + this.lastMempoolAt = 0; + this.lastCheckpointSnapshot = null; // #2: skip the write when nothing moved + } + + // ── checkpoint, written atomically (temp + rename) ───────────────────── + loadCheckpoint() { + if (!this.checkpointFile) return null; + try { + if (!fs.existsSync(this.checkpointFile)) return null; + const parsed = JSON.parse(fs.readFileSync(this.checkpointFile, 'utf8')); + if (!parsed || typeof parsed.checkpoint !== 'string') throw new Error('checkpoint file has no cursor'); + if (parsed.networkId && parsed.networkId !== this.networkId) { + // A checkpoint from another network is not a cursor we can trust: that hash does + // not exist in this DAG, and following it makes the node refuse the next request. + // An install that switched NETWORK in place hits exactly this, so refuse it loudly. + this.log.warn('checkpoint belongs to another network; starting over', + { stored: String(parsed.networkId), running: this.networkId }); + return null; + } + this.checkpoint = parsed.checkpoint; + this.writtenBlocks = Number(parsed.writtenBlocks || 0); + this.ticks = Number(parsed.ticks || 0); + this.log.info('resuming from checkpoint', { + checkpoint: String(parsed.checkpoint).slice(0, 24) + '...', + writtenBlocks: this.writtenBlocks, ticks: this.ticks, + }); + return this.checkpoint; + } catch (error) { + // A corrupt checkpoint must not be fatal: fall back to the pruning point + // and say so, rather than starting from a cursor nobody can trust. + this.log.warn('checkpoint unreadable; starting over', { error: error.message }); + return null; + } + } + + saveCheckpoint() { + // A cursor is a 64-hex block hash or nothing at all: writing anything else plants a value + // the next start cannot follow. (This guard exists because a wrong field name once wrote + // the literal string "undefined" here, and the test file caught it.) + if (!this.checkpointFile) return; + if (typeof this.checkpoint !== 'string' || !/^[0-9a-fA-F]{64}$/.test(this.checkpoint)) return; + // #2 (2026-10-11 audit): this wrote on every tick — a file write and a torn-file window + // per tick even when nothing advanced. Only a moved cursor, floor or tick count is worth + // a rename. + // Deliberately NOT including this.ticks: it changes every tick, which would make the + // guard below dead. Only a moved cursor or write floor is worth a rename. + const snapshot = JSON.stringify([this.checkpoint, this.writtenBlocks]); + if (snapshot === this.lastCheckpointSnapshot) return; + const temporary = this.checkpointFile + '.tmp'; + const payload = JSON.stringify({ + checkpoint: this.checkpoint, writtenBlocks: this.writtenBlocks, ticks: this.ticks, + networkId: this.networkId, savedAt: new Date().toISOString(), + }); + fs.mkdirSync(path.dirname(this.checkpointFile), { recursive: true }); + fs.writeFileSync(temporary, payload); + fs.renameSync(temporary, this.checkpointFile); + this.lastCheckpointSnapshot = snapshot; + } + + // Start cursor: the persisted one if there is one, else the pruning point + // (nothing older exists on the node). + // #3 (2026-10-11 audit): the checkpoint file is local state that can be stale, restored from + // a backup, or copied between hosts. Its write floor decides what the incremental write + // skips, so a wrong floor silently drops rows. The database is the authority. + async reconcileWriteFloor() { + if (typeof this.conn.query !== 'function') return null; + try { + const rows = await this.conn.query('SELECT MAX(block_index) AS max_written FROM `blocks`', []); + const fromDb = Array.isArray(rows) && rows.length && rows[0].max_written !== null + && rows[0].max_written !== undefined ? Number(rows[0].max_written) : null; + if (fromDb !== null && fromDb !== this.writtenBlocks) { + this.log.warn('checkpoint write floor disagrees with the database; trusting the database', + { checkpointSays: this.writtenBlocks, databaseSays: fromDb }); + this.writtenBlocks = fromDb; + } + return fromDb; + } catch (error) { + this.log.warn('write-floor reconciliation skipped', { error: String(error.message || error).slice(0, 120) }); + return null; + } + } + + async init() { + // Order matters, and it is the pin that says so: loadCheckpoint() sets this.writtenBlocks + // from the file, so the database reconciliation has to run AFTER it - otherwise the file's + // floor silently overwrites the database's and rows in between are never written. It runs + // unconditionally: a lost checkpoint file must not make an installation re-write a slice. + const resumed = this.loadCheckpoint(); + await this.reconcileWriteFloor(); + if (resumed) { + return this.checkpoint; + } + const dag = await this.rpc.getBlockDagInfo(); + this.checkpoint = String(dag.pruningPointHash); + this.log.info('starting from the pruning point', { checkpoint: this.checkpoint.slice(0, 24) + '...' }); + return this.checkpoint; + } + + // dataVerbosityLevel takes the enum NAME as a string. Measured: 'High' works, + // numeric 2 and lowercase 'high' are both rejected by the node. + verbosityLevel() { + if (typeof this.verbosity === 'string' && ['None', 'Low', 'High', 'Full'].includes(this.verbosity)) { + return this.verbosity; + } + return { 0: 'None', 1: 'Low', 2: 'High', 3: 'Full' }[Number(this.verbosity)] || 'High'; + } + + // ── one tick, one transaction ───────────────────────────────────────── + async tick() { + const response = await this.rpc.getVirtualChainFromBlockV2({ + startHash: this.checkpoint, + minConfirmationCount: this.minConfirmationCount, + dataVerbosityLevel: this.verbosityLevel(), + }); + + const removed = (response.removedChainBlockHashes || []).map(String); + + // Chain blocks are read defensively: the header is nested under + // chainBlockHeader in the responses the node actually returns, but the + // flat form appears too. A block with no hash at all is skipped loudly. + const chainBlocks = (response.chainBlockAcceptedTransactions || []).map((block) => { + const header = block.chainBlockHeader || {}; + const hash = header.hash || block.hash; + if (!hash) { + this.stats.skippedRows++; + this.log.warn('chain block without a hash was skipped', { keys: Object.keys(block).join(',') }); + return null; + } + return { + hash: String(hash), + previousBlockHash: String(header.selectedParentHash || block.previousBlockHash || ''), + timestamp: header.timestamp !== null && header.timestamp !== undefined ? header.timestamp : null, + acceptedTransactions: block.acceptedTransactions || [], + }; + }).filter(Boolean); + + // Where to roll back to: our own hash -> index map first, then the database. + // After a restart the map is empty, and without the database fallback the + // in-memory state rolled back while the rows and the write floor stayed put — + // so the reverted rows persisted for good and the incremental write never + // revisited them (found in the 2026-10-11 review). + let rollbackFrom = null; + if (removed.length) { + const indexes = removed.map((hash) => this.hashToIndex.get(hash)).filter((v) => v !== null && v !== undefined); + if (indexes.length) { + rollbackFrom = Math.min(...indexes); + } else if (typeof this.conn.query === 'function') { + // blocks.block_hash_id -> index_transactions.hash is the same join the + // platform's own getBlockIndex-style reads use. + for (const hash of removed) { + let rows = []; + try { + rows = await this.conn.query( + 'SELECT b.block_index, b.block_time FROM `blocks` b JOIN `index_transactions` t ON t.id = b.block_hash_id WHERE t.hash = ? LIMIT 1', + [hash]); + } catch (error) { + this.log.warn('rollback index lookup failed', { error: String(error.message || error).slice(0, 120) }); + } + const index = Array.isArray(rows) && rows.length ? Number(rows[0].block_index) : null; + if (index !== null && !Number.isNaN(index)) { + rollbackFrom = rollbackFrom === null ? index : Math.min(rollbackFrom, index); + } + } + if (rollbackFrom === null) { + this.log.error('cannot resolve any removed chain block in memory or in the database; the rows for them may now be stale', + { removed: removed.length }); + } + } + } + // The write floor has to move BEFORE the write plan is built, otherwise the + // replacement chain blocks are filtered out as already-written. This exact + // ordering was once inverted and the reorg case in the service tests caught it. + if (removed.length && rollbackFrom === null) { + // fail-closed. Read this beside the two branches above: when no removed chain block can + // be located in the hash map or (if there is one) in the database, the write floor does + // not move, so the replacement blocks are then filtered out as already-written while + // the removed blocks' rows stay in the table - a divergence that only left an error + // line in the log. A tick that cannot place its own rollback must not write. + throw new Error('cannot resolve any removed chain block in memory or in the database; refusing to write'); + } + if (rollbackFrom !== null) this.writtenBlocks = Math.max(0, rollbackFrom - 1); + // The cursors are rewound here as well, and that is not redundant: ingest's own rollback finds + // the removed block in state, and the working set is trimmed to the floor, so an older removed + // block is usually gone and that rollback rewinds NOTHING. Measured consequence before this + // line existed: the replacement chain was appended after the hole (block_index 3 where a + // from-scratch run writes 2), which is a gap in the table the indexer joins on. + if (rollbackFrom !== null) { + this.state.nextBlockIndex = Math.min(this.state.nextBlockIndex, rollbackFrom); + // nextTxIndex is deliberately NOT rewound. A block_index identifies a position in the + // chain and the replacement chain must reuse it; tx_index is only a surrogate key, and + // rewinding it is how the replacement transaction took the id of a row that was still + // there - an ON DUPLICATE KEY UPDATE then overwrote that survivor's row (measured: the + // row at tx_index 1 held a different transaction after the reorg). Ids stay monotonic. + } + + // Reorg events. The indexer reads them with + // `WHERE id = ? AND code='REORG'` (xchain-indexer/src/db/events/index.js:196), + // and the platform's decoder writes one single-element array per rolled-back + // block (see the comment in src/sql/events.sql). + // + // The event time is CHAIN data, never the wall clock: two nodes replaying the same + // chain must produce byte-identical rows (whitepaper §1.2, "no wall-clock-dependent + // branching"). The rolled-back block's own block_time is the deterministic source; + // when even that is unknown the column stays NULL rather than being filled with now. + // #4 (2026-10-11 audit): this map used to be rebuilt on EVERY tick over all history — O(n) + // per tick, forever. Times are only needed for the blocks being rolled back, and removals + // are rare, so it is built only when there is something to roll back. + const chainTimeByHash = new Map(); + if (removed.length) { + for (const b of this.state.blocks) chainTimeByHash.set(b.block_hash, b.block_time); + // The working set is trimmed to the write floor (trimToFloor), so an older removed block + // is usually NOT in state any more - and its event time must still come from the chain, + // never from the clock (two nodes replaying one chain have to agree). The floor itself may + // have come from the service's own hash map, in which case no query ran for it at all, so + // this is the only place left that can answer. Removals are rare, so one query per + // unresolved hash is the cheap side of the trade; a hash nobody can resolve stays NULL. + for (const hash of removed) { + if (chainTimeByHash.has(hash) || typeof this.conn.query !== 'function') continue; + try { + const rows = await this.conn.query( + 'SELECT b.block_time FROM `blocks` b JOIN `index_transactions` t ON t.id = b.block_hash_id WHERE t.hash = ? LIMIT 1', + [hash]); + if (Array.isArray(rows) && rows.length && rows[0].block_time !== undefined && rows[0].block_time !== null) { + chainTimeByHash.set(hash, rows[0].block_time); + } + } catch (error) { + this.log.warn('removed block time lookup failed', { error: String(error.message || error).slice(0, 120) }); + } + } + } + for (const hash of removed) { + const index = this.hashToIndex.get(hash); + const seconds = chainTimeByHash.has(hash) ? chainTimeToSeconds(chainTimeByHash.get(hash)) : null; + this.state.events.push({ + time: seconds === null ? null : new Date(seconds * 1000).toISOString().slice(0, 19).replace('T', ' '), + code: 'REORG', + data: JSON.stringify([{ block_index: index === undefined ? null : index, block_hash: hash }]), + }); + } + + // In-memory state first (pure functions, replayable). + if (removed.length) { + rollback(this.state, removed); + removed.forEach((hash) => this.hashToIndex.delete(hash)); + } + let rows = this.state.rows(); + if (chainBlocks.length) { + const before = this.state.nextBlockIndex; + rows = ingest(chainBlocks, { state: this.state }).rows; + let index = before; + for (const block of chainBlocks) { this.hashToIndex.set(block.hash, index); index++; } + while (this.hashToIndex.size > this.hashToIndexLimit) { + const oldest = this.hashToIndex.keys().next().value; + this.hashToIndex.delete(oldest); + } + } + + const deletePlan = rollbackFrom !== null ? buildRollbackPlan(rollbackFrom) : []; + const newEvents = this.state.events.slice(this.eventsFlushed); // only this tick's events + const writePlan = buildWritePlan(rows, { sinceBlockIndex: this.writtenBlocks, events: newEvents }); + const nextWritten = rows.blocks.reduce((max, b) => Math.max(max, Number(b.block_index)), this.writtenBlocks); + + if (deletePlan.length || writePlan.length) { + await withTransaction(this.conn, async () => { + if (deletePlan.length) await applyPlan(this.conn, deletePlan); + if (writePlan.length) await applyPlan(this.conn, writePlan); + }); + this.stats.deleted += deletePlan.length; + this.stats.inserted += writePlan.length; + if (deletePlan.length) this.stats.rollbacks += 1; + this.writtenBlocks = Math.max(this.writtenBlocks, nextWritten); + // The flushed events are in the database and the cursor has moved: holding them + // in memory for the lifetime of the process is a slow leak on a chain that reorgs. + this.state.events = []; + this.eventsFlushed = 0; + } + + // The cursor follows what was actually WRITTEN, not an optional response field: a response + // may carry only chainBlockAcceptedTransactions, and a checkpoint that never advances + // re-ingests the whole slice on every restart - idempotent, but no progress at all. + const added = (response.addedChainBlockHashes || []).map(String); + // rows.blocks is already projected (block_index + block_hash_id), so the cursor has to + // come from the ingest state, which still holds the hashes. + const written = (this.state.blocks || []).slice() + .sort((a, b) => Number(a.block_index) - Number(b.block_index)); + if (written.length) this.checkpoint = String(written[written.length - 1].block_hash); + else if (added.length) this.checkpoint = added[added.length - 1]; + // Only AFTER the cursor above: the checkpoint is read out of state.blocks, so trimming first + // would move it backwards to whatever block happens to be left. This is the fix for the + // unbounded working set - every tick used to keep its rows forever, so memory grew for the + // life of the process and each tick re-materialised the whole history. + if (typeof this.state.trimToFloor === 'function') this.state.trimToFloor(this.writtenBlocks); + this.ticks++; + this.saveCheckpoint(); + + return { + tick: this.ticks, addedBlocks: chainBlocks.length, removedBlocks: removed.length, + rollbackFrom, writeStatements: writePlan.length, deleteStatements: deletePlan.length, + blocks: rows.blocks.length, transactions: rows.transactions.length, + outputs: rows.transaction_outputs.length, checkpoint: this.checkpoint, + }; + } + + // ── main loop: classify, back off, propagate the fatal ───────────────── + async run({ intervalMs = 5000, maxTicks = Infinity, onTick = null } = {}) { + let consecutive = 0; + // Initialisation belongs INSIDE the retry loop, for the same reason ticks do. Measured on a + // closed port: init() threw ECONNREFUSED, the part's start promise rejected, and the reader + // never started - while a node that goes down LATER is retried with backoff. "The node is not + // up yet" is the common case on a fresh boot, and it was the one case that ended everything. + let initialised = this.checkpoint !== null; + for (let i = 0; i < maxTicks && !this.stopping; i++) { + try { + if (!initialised) { await this.init(); initialised = true; } + const result = await this.tick(); + this.backoffMs = this.baseBackoffMs; // success resets the backoff + consecutive = 0; + this.log.info('tick done', { + tick: result.tick, added: result.addedBlocks, removed: result.removedBlocks, + wrote: result.writeStatements, deleted: result.deleteStatements, + }); + if (typeof onTick === 'function') onTick(result); + if (i + 1 < maxTicks && !this.stopping) await this.sleep(intervalMs); + } catch (error) { + this.stats.errors++; + if (classifyError(error) === 'fatal') { + this.log.error('fatal error, stopping the loop', { error: String(error.message || error) }); + throw error; + } + this.stats.recoverable++; + consecutive++; + if (consecutive > this.maxRecoverableRetries) { + this.log.error('too many consecutive recoverable failures, handing back', { + consecutive, limit: this.maxRecoverableRetries, + error: String(error.message || error).slice(0, 160), + }); + throw error; + } + this.log.warn('recoverable error, backing off', { + consecutive, error: String(error.message || error).slice(0, 160), backoffMs: this.backoffMs, + }); + await this.sleep(this.backoffMs); + this.backoffMs = Math.min(this.maxBackoffMs, Math.round(this.backoffMs * 2)); + i--; // a recoverable failure does not use up maxTicks + } + } + return this.stats; + } + + requestStop() { this.stopping = true; } + + // Mempool mirror (the platform refreshes its own on an interval). Write side + // only: the caller supplies entries taken from the node's mempool RPC, because + // which RPC is an installation decision, not one to guess here. + async pollMempoolOnce(entries, { now = Date.now() } = {}) { + if (now - this.lastMempoolAt < this.mempoolIntervalMs) return { skipped: true, reason: 'interval-not-reached' }; + this.lastMempoolAt = now; + const plan = buildMempoolWritePlan(entries); + if (!plan.length) return { skipped: false, written: 0 }; + await applyPlan(this.conn, plan); + this.stats.mempoolWritten += plan.length; + this.log.info('mempool mirror written', { rows: plan.length }); + return { skipped: false, written: plan.length }; + } + + sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +} + +module.exports = { KaspaDecoderService, classifyError, createConsoleLogger, RECOVERABLE_PATTERNS }; diff --git a/src/chain/kaspa/kaspa_store.js b/src/chain/kaspa/kaspa_store.js new file mode 100644 index 00000000..18deca17 --- /dev/null +++ b/src/chain/kaspa/kaspa_store.js @@ -0,0 +1,190 @@ +/********************************************************************* + * + * 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 store projection (pure SQL text — offline testable) + * + * Input : the rows() produced by kaspa_ingest.js + * Output: INSERT / DELETE statements against the platform's decoder tables + * + * Why this is its own layer: the SQL text is pure, so it can be asserted + * offline; the driver (mariadb) is only needed when a database actually + * exists, so nothing here requires a driver. + * + * Two behaviours taken from the platform rather than invented: + * · Lookup tables (index_transactions / index_addresses) are written with + * INSERT ... ON DUPLICATE KEY UPDATE — ids are append-only and a replayed + * slice re-inserts the same ids. + * · Block-scoped tables are deleted for real on rollback; the replay that + * follows re-inserts them. Nothing is tombstoned. + ********************************************************************/ +'use strict'; + +const SCHEMA = { + index_transactions: ['id', 'hash'], + index_addresses: ['id', 'address'], + blocks: ['block_index', 'block_time', 'block_hash_id', 'previous_block_hash_id'], + transactions: ['tx_index', 'tx_hash_id', 'block_index', 'source_id', 'destination_id', 'amount', 'fee', 'data', 'raw_data'], + transaction_outputs: ['tx_index', 'vout', 'destination_id', 'amount'], + pubkeys: ['address_id', 'pubkey'], + dispensers: ['tx_index', 'address_id', 'expiration', 'oracle_address_id', 'source_address_id', 'expired_block_index'], + events: ['time', 'code', 'data'], + mempool_transactions: ['tx_hash', 'source', 'destination', 'amount', 'fee', 'data', 'raw_data'], +}; + +const q = (ident) => '`' + String(ident).replace(/`/g, '``') + '`'; + +function insertSql(table, row, { upsert = false } = {}) { + const columns = SCHEMA[table]; + if (!columns) throw new Error('unknown table: ' + table); + const values = columns.map((c) => (row[c] === undefined ? null : row[c])); + let sql = 'INSERT INTO ' + q(table) + ' (' + columns.map(q).join(', ') + ') VALUES (' + + columns.map(() => '?').join(', ') + ')'; + if (upsert) { + // Lookup tables: a repeated id overwrites; every other column is untouched + // by the ON DUPLICATE arm only when the id matches, which is what makes + // replay idempotent. + const updates = columns.filter((c) => c !== 'id') + .map((c) => q(c) + ' = VALUES(' + q(c) + ')').join(', '); + sql += ' ON DUPLICATE KEY UPDATE ' + updates; + } + return { sql, values }; +} + +// rows() -> ordered statements. Order is the foreign-key order: index_* first +// (other tables reference their ids), then blocks, then transactions, then the +// children. `events` is passed explicitly (see the cursor in kaspa_service.js): +// rows.events accumulates, so writing all of it every tick would duplicate. +function buildWritePlan(rows, { sinceBlockIndex = 0, events = [] } = {}) { + const plan = []; + + // Events are INDEPENDENT of the block delta and must be emitted even when nothing new + // arrived: a reorg can come back as removals with no additions, and the indexer's + // witness (`WHERE id=? AND code='REORG'`) still has to see it. An earlier version + // returned [] right here, so the service advanced its flush cursor over events that + // were never written, and they were lost for good. + for (const r of events) plan.push(insertSql('events', r)); + + const newBlocks = (rows.blocks || []).filter((b) => Number(b.block_index) > Number(sinceBlockIndex)); + if (!newBlocks.length) return plan; + + const newTx = new Set((rows.transactions || []) + .filter((t) => Number(t.block_index) > Number(sinceBlockIndex)) + .map((t) => t.tx_index)); + + for (const r of rows.index_transactions || []) plan.push(insertSql('index_transactions', r, { upsert: true })); + for (const r of rows.index_addresses || []) plan.push(insertSql('index_addresses', r, { upsert: true })); + // These three carry the block's own data and were the only statements without an upsert. + // The real-database integration test caught what that costs: re-writing a slice that is + // already in the table - a lost checkpoint, a manual re-run, a reorg that re-adds an index - + // died on a duplicate primary key, and that error is not in the recoverable class, so the + // reader stopped. Idempotent here means the same slice can always be written again. + for (const r of newBlocks) plan.push(insertSql('blocks', r, { upsert: true })); + for (const r of rows.transactions || []) if (newTx.has(r.tx_index)) plan.push(insertSql('transactions', r, { upsert: true })); + for (const r of rows.transaction_outputs || []) if (newTx.has(r.tx_index)) plan.push(insertSql('transaction_outputs', r, { upsert: true })); + for (const r of rows.pubkeys || []) plan.push(insertSql('pubkeys', r, { upsert: true })); + for (const r of rows.dispensers || []) if (newTx.has(r.tx_index)) plan.push(insertSql('dispensers', r, { upsert: true })); + return plan; +} + +// Rollback deletes every block-scoped row at or above `since`. Lookup tables are +// left alone (append-only). Order is the reverse of insertion: children first. +function buildRollbackPlan(since) { + const b = Number(since); + if (!Number.isFinite(b) || b < 0) throw new Error('since must be a non-negative integer'); + return [ + // transaction_outputs has no block_index of its own; join through the parent. + { sql: 'DELETE o FROM `transaction_outputs` o JOIN `transactions` t ON t.tx_index = o.tx_index WHERE t.block_index >= ?', values: [b] }, + // Same join for the dispenser registrations. Events are append-only: never deleted. + { sql: 'DELETE d FROM `dispensers` d JOIN `transactions` t ON t.tx_index = d.tx_index WHERE t.block_index >= ?', values: [b] }, + { sql: 'DELETE FROM `transactions` WHERE block_index >= ?', values: [b] }, + { sql: 'DELETE FROM `blocks` WHERE block_index >= ?', values: [b] }, + // pubkeys / index_* are deliberately absent: in the platform DDL pubkeys is + // keyed by address_id with no block_index column, i.e. lookup data. The + // replay rewrites it, and ON DUPLICATE KEY keeps that idempotent. + ]; +} + +// Execution is injected: conn must expose query(sql, values). +async function applyPlan(conn, plan) { + const done = []; + for (const statement of plan) { + await conn.query(statement.sql, statement.values); + done.push(statement.sql.split(' ').slice(0, 3).join(' ')); + } + return done; +} + +// One tick's delete+write must be atomic: a half-written block is worse than a +// failed tick. Connections without transactions (dry runs, fakes) pass through. +async function withTransaction(conn, fn) { + const hasTransaction = typeof conn.beginTransaction === 'function' && typeof conn.commit === 'function'; + if (!hasTransaction) return await fn(); + await conn.beginTransaction(); + try { + const out = await fn(); + await conn.commit(); + return out; + } catch (error) { + try { await conn.rollback(); } + catch (rollbackError) { + // Never mask the original failure: attach the rollback failure and rethrow. + error.rollbackError = String(rollbackError && rollbackError.message ? rollbackError.message : rollbackError); + } + throw error; + } +} + +// Schema scripts in this repo are per-table DROP+CREATE (src/sql/*.sql), i.e. meant +// for a fresh database. Running one wholesale on every start would empty the DB, so +// the default path rewrites DROP away and adds IF NOT EXISTS; duplicate-index and +// duplicate-seed-row errors are then expected and ignored by the caller. +// `--reset-schema` is the explicit destructive path. +function toIdempotent(sql) { + if (/^DROP TABLE/i.test(sql)) return null; + return sql.replace(/^CREATE TABLE /i, 'CREATE TABLE IF NOT EXISTS '); +} + +// Mempool mirror. mempool_transactions is an append-style snapshot table (the +// platform refreshes it on an interval), deduped by its UNIQUE tx_hash. +// entries: [{ tx_hash, source, destination, amount, fee, data, raw_data }] +function buildMempoolWritePlan(entries) { + const plan = []; + for (const entry of entries || []) { + if (!entry || !entry.tx_hash) continue; // no hash -> no half row + plan.push(insertSql('mempool_transactions', { + tx_hash: String(entry.tx_hash), + source: entry.source || null, + destination: entry.destination || null, + // Strings, not Number(): these are sompi amounts, and Number() silently rounds past + // 2^53-1. The platform writes transaction_outputs.amount as a string for the same reason. + amount: entry.amount === null || entry.amount === undefined ? null : String(entry.amount), + fee: entry.fee === null || entry.fee === undefined ? '0' : String(entry.fee), + data: entry.data === null || entry.data === undefined ? null : String(entry.data), + raw_data: entry.raw_data || null, + }, { upsert: true })); + } + return plan; +} + +module.exports = { + SCHEMA, + insertSql, + buildWritePlan, + buildRollbackPlan, + applyPlan, + withTransaction, + toIdempotent, + buildMempoolWritePlan, +}; diff --git a/src/coins/KAS.js b/src/coins/KAS.js new file mode 100644 index 00000000..3d166f7b --- /dev/null +++ b/src/coins/KAS.js @@ -0,0 +1,272 @@ +/********************************************************************* + * + * 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 Platform - Canonical Coin Definition - Kaspa (KAS) + * + * This is the first coin file outside the Bitcoin family. Three points need upstream sign-off: + * + * 1. wireFormat introduces a new family name, 'kaspa'. The platform decoder's whitelist + * (xchain-decoder/src/chain/XChainBlockDecoder.js:32 `HANDLED_WIRE_FORMATS`) + * holds only 'default'/'mweb'/'auxpow' and throws on anything else, so + * a Kaspa parse path (or a separate Kaspa decoder service) has to land at the same time. + * This is the one place where upstream has to make a call. + * + * 2. `net` is not a bitcoinjs network object: Kaspa has no base58check/WIF/pkh bytes. + * It is Kaspa-side configuration; consumers must branch on wireFormat before reading it. + * + * 3. Every address role below is a placeholder, on a network prefix we control. + * Replace them with the real role addresses (BURN/GAS/REWARD/BRIDGE_*) before mainnet. + * + * Where the values come from (measured or read from source, never estimated): + * · decimals = 8 —— `SOMPI_PER_KASPA = 100_000_000` + * (rusty-kaspa consensus/core/src/constants.rs) + * · fee model - minimum relay fee 100 sompi/gram + * (wallet/core/src/tx/mass.rs MINIMUM_RELAY_TRANSACTION_FEE) + * · 8 KB payload mass = 18,420, - measured on testnet-10 with the local harness + * · 10 blocks per second - params.rs `BlockrateParams::new::<10>()` + * · ports / encoding - kaspad/src/args.rs; wRPC-Borsh on 17110 measured working + * + ********************************************************************/ +module.exports = { + + // ── IDENTITY ──────────────────────────────────────────────────────────── + tick: 'KAS', // native ticker (the platform uses the coin symbol, not the chain name) + fullName: 'kaspa', // hub configs-table coin key + COIN_FULL_NAME + displayName: 'Kaspa', // explorer chain.name + site: 'https://kaspa.org', + decimals: 8, // sompi(1 KAS = 100,000,000 sompi)✓ + + // Cross-chain attestation depth. Kaspa is a DAG at 10 BPS: 100 chain blocks is about 10 seconds. + // (the platform counts blocks for BTC/LTC/DOGE too; same measure here, different pace.) + confirmations: 100, + + // New family. See point 1 in the file header. + wireFormat: 'kaspa', + + DISPLAY_ONLY_ADDRESS_ROLES: ['EXPLORER'], + + // ── family descriptor (new-family only; not a bitcoinjs field) ───────────────────────────── + // The platform's `net` block describes the bitcoinjs family only, so Kaspa's own facts live here + // and are read by consumers that branch on the family. Ports come from the help text in + // kaspad/src/args.rs (mainnet and testnet sets); the simnet default is unverified and must be + FAMILY: { + id: 'kaspa', + addressPrefix: { mainnet: 'kaspa', testnet: 'kaspatest', regtest: 'kaspasim' }, + carrier: 'payload', // the transaction carries payload natively; this is not OP_RETURN + rpcPorts: { + mainnet: { grpc: 16110, p2p: 16111, wrpcBorsh: 17110, wrpcJson: 18110 }, + testnet: { grpc: 16210, p2p: 16211, wrpcBorsh: 17210, wrpcJson: 18210 }, + regtest: null, // unverified; must be passed explicitly + }, + }, + + // ── PER-NETWORK PARAMS ────────────────────────────────────────────────── + networks: { + + mainnet: { + // Kaspa-shaped, not bitcoinjs-shaped: consumers must branch on wireFormat. + // The Kaspa family has no bitcoinjs network object (the platform feeds `net` straight to + // bitcoinjs - see xchain-decoder/src/XChainDecoder.js:92). It is null here on purpose: consumers + // must branch on wireFormat and fail loudly; the Kaspa facts live in the coin-level FAMILY block. + net: null, + // Start index of the linear projection (not a chain height: Kaspa has none). + // IS consensus-hashed; the mainnet value must be fixed once, with the network. + firstBlock: 0, + // Read the genesis hash from the node (`getBlock` at the DAG origin) or the official docs. + // Unpinned means the check is skipped (platform semantics): do not guess. + chainGenesisHash: null, + addresses: { + // Placeholders: replace with the real role addresses before mainnet. + BURN: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + GAS: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + DONATE1: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + DONATE2: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + FEE_DESTINATION: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + REWARD: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + BRIDGE_BTC: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + BRIDGE_LTC: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + BRIDGE_DOGE: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + EXPLORER: 'kaspa:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtynknmzpl6', + }, + genesis: { + block: 0, ledgerHash: null, dumpHash: null, + airdropPaths: [], airdropHashes: [], airdropAmounts: [], + airdropSnapshotBlock: null, airdropSetHash: null, + }, + }, + + testnet: { + // The Kaspa family has no bitcoinjs network object (the platform feeds `net` straight to + // bitcoinjs - see xchain-decoder/src/XChainDecoder.js:92). It is null here on purpose: consumers + // must branch on wireFormat and fail loudly; the Kaspa facts live in the coin-level FAMILY block. + net: null, + firstBlock: 0, + chainGenesisHash: null, // read from the node; do not guess + addresses: { + BURN: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + GAS: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + DONATE1: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + DONATE2: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + FEE_DESTINATION: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + REWARD: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + BRIDGE_BTC: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + BRIDGE_LTC: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + BRIDGE_DOGE: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + EXPLORER: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7', + }, + genesis: { + // testnet starts clean, following the LTC/testnet example + block: 0, ledgerHash: null, dumpHash: null, + airdropPaths: [], airdropHashes: [], airdropAmounts: [], + airdropSnapshotBlock: null, airdropSetHash: null, + }, + }, + + regtest: { + // The Kaspa family has no bitcoinjs network object (the platform feeds `net` straight to + // bitcoinjs - see xchain-decoder/src/XChainDecoder.js:92). It is null here on purpose: consumers + // must branch on wireFormat and fail loudly; the Kaspa facts live in the coin-level FAMILY block. + net: null, + firstBlock: 0, + chainGenesisHash: null, // simnet builds a fresh chain each run, so it is never pinned + addresses: { + // simnet is better overridden from the environment; this is a runnable default + BURN: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + GAS: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + DONATE1: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + DONATE2: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + FEE_DESTINATION: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + REWARD: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + BRIDGE_BTC: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + BRIDGE_LTC: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + BRIDGE_DOGE: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + EXPLORER: 'kaspasim:qr9htftcq77c02a83mfrhmu8mw7v7szgk7aanu6g7npfffhwdc2a5ysp42wzg', + }, + genesis: { + $envOverrides: { + block: { env: 'XCHAIN_GENESIS_BLOCK', type: 'int', default: 0 }, + ledgerHash: { env: 'XCHAIN_GENESIS_LEDGER_HASH', type: 'str', default: null }, + dumpHash: { env: 'XCHAIN_GENESIS_DUMP_HASH', type: 'str', default: null }, + airdropPaths: { env: 'GENESIS_AIRDROP_PATHS', type: 'csv', default: [] }, + airdropHashes: { env: 'GENESIS_AIRDROP_HASHES', type: 'csv_sparse', default: [] }, + airdropAmounts: { env: 'GENESIS_AIRDROP_AMOUNTS', type: 'csv_sparse', default: [] }, + airdropSnapshotBlock: { env: 'GENESIS_AIRDROP_SNAPSHOT_BLOCK', type: 'str', default: null }, + airdropSetHash: { env: 'GENESIS_AIRDROP_SET_HASH', type: 'str', default: null }, + }, + }, + }, + }, + + // ── COIN-LEVEL CONSENSUS PARAMS (network-independent) ──────────────────── + // These are priced in XCHAIN (independent of the host chain), so they match the BTC side; + // only the block-count shaped parameters follow the Kaspa pace, noted line by line below. + + legacyFees: { + ISSUANCE_FEE_TOKEN: '1.00000000', + ISSUANCE_FEE_SUBTOKEN: '0.50000000', + EXPIRATION_FEE_DEFAULT_DAYS: 90, + EXPIRATION_FEE_FREE_DAYS: 182, + EXPIRATION_FEE_PER_DAY: '0.00547945', + }, + + GAS_PRICE: '0.00001', // XCHAIN per gas unit + UNIFIED_EXPIRATION_FEE_FREE_DAYS: 90, + // Design decision: fees are charged from the XCHAIN balance, not from native KAS. + // FEE_PAYMENT_MODE is a non-consensus field (the guide files it under Display/connection), so + // the xchain mode avoids the KAS oracle price and its tolerance band. + FEE_PAYMENT_MODE: 'xchain', + FEE_TOLERANCE_MIN: '0.95', + FEE_TOLERANCE_MAX: '1.10', + ORACLE_MAX_PRICE_AGE_SECONDS: 1800, + ORACLE_MAX_PRICE_AGE_HOURLY_SECONDS: 4500, + VALIDATOR_QUERY_LIMIT: 1000, + + // Gas schedule identical to BTC (denominated in XCHAIN, not the host coin). + GAS_SCHEDULE: { + ISSUE: 100000, + ISSUE_SUBTOKEN: 50000, + EXPIRATION_PER_DAY: 550, + OWNERSHIP_ESCROW: 50000, + AIRDROP_PER_RECIPIENT: 100, + DIVIDEND_PER_RECIPIENT: 100, + SWEEP_BASE: 5000, + SWEEP_PER_ITEM: 100, + CALLBACK_BASE: 5000, + CALLBACK_PER_RECIPIENT: 100, + XBRIDGE_BASE: 5000, + LIST_SHARE: 100000, + LIST_SHARED_EDIT_BASE: 5000, + LIST_SHARED_EDIT_PER_ITEM: 100, + BET_FEED_PER_DAY: 550, + BET_PER_CREDIT: 100, + VM_EXECUTE_BASE: 1000, + VM_DEPLOY_BASE: 100000, + VM_DEPLOY_PER_BYTE: 10, + VM_STATE_READ: 100, + VM_STATE_WRITE: 200, + VM_STATE_DELETE: 100, + VM_ORACLE_READ: 100, + VM_CROSSCHAIN_READ: 100, + VM_ATTEST_REQUEST: 5000, + VM_XCALL_REQUEST: 2000, + VM_XCALL_CALLBACK: 20000, + VM_EMISSION: 500, + VM_COMPUTATION: 1, + VM_GUARD_GAS_CEILING: 200000, + }, + + STAKING: { + // Scaled for the 10 BPS pace (the platform values assume a block about every 10 minutes; + // the factor is 600 s / 0.1 s = 6000). + COOLDOWN_BLOCKS: 6048000, // 7 days (1000 × 6000) + ACTIVATION_DELAY_BLOCKS: 36000, // 1 hour (6 × 6000) + CAPABILITIES: { + // MIN_STAKE is priced in XCHAIN, independent of the host chain, so it keeps the BTC value + price: { MIN_STAKE: '1000.00000000', SLASH: { BOUNTY_BPS: 500, BOUNTY_FLOOR: '50.00000000', BOUNTY_CAP: '1000.00000000' } }, + cross_chain: { MIN_STAKE: '5000.00000000', SLASH: { BOUNTY_BPS: 500, BOUNTY_FLOOR: '50.00000000', BOUNTY_CAP: '1000.00000000' } }, + oracle_publish: { MIN_STAKE: '500.00000000', SLASH: { BOUNTY_BPS: 500, BOUNTY_FLOOR: '50.00000000', BOUNTY_CAP: '1000.00000000' } }, + attestation: { MIN_STAKE: '1000.00000000', SLASH: { BOUNTY_BPS: 500, BOUNTY_FLOOR: '50.00000000', BOUNTY_CAP: '1000.00000000' } }, + full_node: { MIN_STAKE: '2000.00000000' }, + }, + }, + + CONFIG_SLASH: { BOUNTY_BPS: 500, BOUNTY_FLOOR: '50.00000000', BOUNTY_CAP: '1000.00000000' }, + + FULLNODE: { + // Scaled by the same factor; this layer is inert for now + // (REWARD_SHARE '0' with an empty GENESIS_VERIFIERS means unactivated: "ships inert until a later flag-day", + // in the platform's own words), so these are shaped for the Kaspa pace and do not affect consensus until then. + CHALLENGE_INTERVAL_BLOCKS: 864000, + CONFIRM_DEPTH: 600000, + PROOF_WINDOW_BLOCKS: 1800000, + VERDICT_ACCEPT_WINDOW_BLOCKS: 144000, + REWARD_SHARE: '0', + REWARD_PASS_WINDOW_BLOCKS: 6048000, + MIN_PASS_RATE_BPS: 7000, + COLLECT_DEPTH_BLOCKS: 18000, + GENESIS_VERIFIERS: [], + $regtestSidecar: 'fullnode.regtest.json', + $regtestEnvOverrides: { + CHALLENGE_INTERVAL_BLOCKS: { env: 'FULLNODE_CHALLENGE_INTERVAL_BLOCKS', type: 'int' }, + CONFIRM_DEPTH: { env: 'FULLNODE_CONFIRM_DEPTH', type: 'int' }, + PROOF_WINDOW_BLOCKS: { env: 'FULLNODE_PROOF_WINDOW_BLOCKS', type: 'int' }, + VERDICT_ACCEPT_WINDOW_BLOCKS: { env: 'FULLNODE_VERDICT_ACCEPT_WINDOW_BLOCKS', type: 'int' }, + REWARD_PASS_WINDOW_BLOCKS: { env: 'FULLNODE_REWARD_PASS_WINDOW_BLOCKS', type: 'int' }, + MIN_PASS_RATE_BPS: { env: 'FULLNODE_MIN_PASS_RATE_BPS', type: 'int' }, + REWARD_SHARE: { env: 'FULLNODE_REWARD_SHARE', type: 'str' }, + COLLECT_DEPTH_BLOCKS: { env: 'FULLNODE_COLLECT_DEPTH_BLOCKS', type: 'int' }, + GENESIS_VERIFIERS: { env: 'FULLNODE_GENESIS_VERIFIERS', type: 'csv_lower' }, + }, + }, +}; diff --git a/src/coins/consensus_pin.js b/src/coins/consensus_pin.js index 5e0717c0..2b260a14 100644 --- a/src/coins/consensus_pin.js +++ b/src/coins/consensus_pin.js @@ -102,12 +102,14 @@ module.exports = { BTC: '3e0083d555399e54eff2670a0ea5e8d76c8935d3e7423451c1221ae698386b5a', LTC: 'd9c315b36adf5ab8bd6831ccc59a2a01124df70ba9fc8141662ec682285251e8', DOGE: '82c6bc814e3de4a1e023d008457b7186c5a6ac142c85be9bf0a18f2d5989fd72', + KAS: 'a5eceb4f9c0ef85090312510daddea3b6f171de59f7867f37aef2a2d8a5a7b7c', // Added: non-Bitcoin family (see the KAS.js header) }, regtest: { BTC: 'ad800ecb8d28448f9d39743bd3a28e6aac121d0e9add30743ee1dcbbf694af86', LTC: '16dd43ca2299880704e076e5f7c262eae6b5e32823ad49c9748c33f014f44c36', DOGE: 'd7bd53f527d961f1cf0cfc7de725d61e625975f7086532976c777a04c08988f6', + KAS: 'b81fe5581c8108575b06910de1749981a0518bf918a6277b05905ebff70c3d1c', // Added: non-Bitcoin family (see the KAS.js header) }, }, }; diff --git a/src/coins/index.js b/src/coins/index.js index f4f6a364..56067513 100644 --- a/src/coins/index.js +++ b/src/coins/index.js @@ -44,6 +44,7 @@ const COIN_FILES = { BTC: require('./BTC.js'), LTC: require('./LTC.js'), DOGE: require('./DOGE.js'), + KAS: require('./KAS.js'), // new: non-Bitcoin family, wireFormat 'kaspa' }; const ALLOWED_COINS = Object.keys(COIN_FILES); diff --git a/stryker.kaspa.config.json b/stryker.kaspa.config.json new file mode 100644 index 00000000..f83daebd --- /dev/null +++ b/stryker.kaspa.config.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker/master/packages/core/schema/stryker-core.json", + "comment": "Mutation run scoped to the Kaspa family modules. Same runner shape as xchain-node's config. MEASURED 2026-10-13: kaspa_store.js 73.88% - 198 killed / 43 survived / 27 runtime errors out of 268 mutants. The thresholds below are xchain-node's (high 90 / low 70 / break 70) and they are real: the measured score clears break. The 27 runtime errors are mutants that make a call throw before any assertion runs - a blanked table-name literal, an emptied SCHEMA, an inverted 'if (!columns) throw'. They crash with this module's own 'unknown table' error, so they are detected in practice, but this Stryker version reports them separately and does not count them in the score: with them counted the same run is about 84%. Converting them is mechanical (keep every throwing call inside an it()), and it is the next thing to do rather than something to hide.", + "mutate": [ + "src/chain/kaspa/**/*.js", + "src/XChainDecoder/kaspa_sync.js" + ], + "testRunner": "mocha", + "mochaOptions": { + "spec": [ + "test/unit/chain/kaspa/*.test.js" + ], + "no-config": true, + "no-opts": true + }, + "reporters": [ + "clear-text" + ], + "coverageAnalysis": "off", + "concurrency": 1, + "thresholds": { + "high": 90, + "low": 70, + "break": 70 + } +} diff --git a/test/benchmarks/kaspa_ingest_bench.js b/test/benchmarks/kaspa_ingest_bench.js new file mode 100644 index 00000000..6c3bf8d6 --- /dev/null +++ b/test/benchmarks/kaspa_ingest_bench.js @@ -0,0 +1,75 @@ +/********************************************************************* + * + * 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 any AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * Benchmark the projection path: a captured real chain block through ingest -> write plan. + * + * node test/benchmarks/kaspa_ingest_bench.js [rounds] [blocks-per-round] + * + * Why this exists next to the unit tests: the working set used to grow for the whole life of the + * process, so a tick's cost grew with everything already processed. Nothing in a correctness suite + * can see that - only a number over repeated rounds can. The benchmark prints per-round timing, so + * a regression toward O(history) shows up as a rising sequence rather than a single slow number. + * No thresholds are asserted: a shared CI box cannot promise wall-clock times, and a flaky budget + * would be worse than no budget. It prints; a human reads the trend. + **********************************************************************/ +'use strict'; +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const { ingest, IngestState } = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_ingest.js')); +const { buildWritePlan } = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_store.js')); + +const ROUNDS = Number(process.argv[2] || 8); +const PER_ROUND = Number(process.argv[3] || 25); +const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'kaspa', 'chain_block_with_payload.json'); + +const fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')); +const withTxs = fixture.acceptedTransactions.filter((t) => t.inputs && t.inputs.length); + +const round = (n) => { + // Each round is a fresh chain of blocks: same transactions, distinct hashes and timestamps, + // which is what the projection actually costs money on (hash ids, address ids, dispenser rows). + const blocks = []; + for (let i = 0; i < PER_ROUND; i += 1) { + blocks.push({ + hash: String(n).padStart(2, '0').repeat(32) + String(i).padStart(2, '0').repeat(0), + previousBlockHash: String(n).padStart(2, '0').repeat(32), + timestamp: 1791716119000 + n * 1000 + i, + acceptedTransactions: withTxs, + }); + } + return blocks; +}; + +const state = new IngestState(); +const timings = []; +for (let n = 1; n <= ROUNDS; n += 1) { + const blocks = round(n); + const started = process.hrtime.bigint(); + const rows = ingest(blocks, { state }).rows; + const plan = buildWritePlan(rows, { sinceBlockIndex: 0, events: [] }); + const ms = Number(process.hrtime.bigint() - started) / 1e6; + timings.push(ms); + console.log(' round ' + String(n).padStart(2) + ': ' + ms.toFixed(2) + ' ms blocks=' + rows.blocks.length + + ' txs=' + rows.transactions.length + ' statements=' + plan.length + + ' state.blocks=' + state.blocks.length + ' state.txs=' + state.transactions.length); +} + +const first = timings[0]; +const last = timings[timings.length - 1]; +console.log('kaspa_ingest_bench: ' + ROUNDS + ' rounds x ' + PER_ROUND + ' blocks, first ' + first.toFixed(2) + + ' ms, last ' + last.toFixed(2) + ' ms, ratio ' + (last / first).toFixed(2) + 'x'); +console.log(' A ratio near 1 means the per-round cost does not follow history; a steadily rising ' + + 'sequence means the working set (or the plan) is growing with it.'); diff --git a/test/chaos/kaspa_reorg_chaos.js b/test/chaos/kaspa_reorg_chaos.js new file mode 100644 index 00000000..4575155d --- /dev/null +++ b/test/chaos/kaspa_reorg_chaos.js @@ -0,0 +1,162 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************** + * + * Chaos: random reorg sequences against the service, with a seeded generator. + * + * node test/chaos/kaspa_reorg_chaos.js [sequences] [seed] + * + * Reorg handling is where this family carries the most logic (rollback, floor, cursors, event + * times) and, measured twice, where the interesting defects lived. A hand-written case covers the + * one shape its author thought of; this walks many shapes - removals of depth 1..N, removals of + * blocks that were never ours, empty slices, replays - and after every sequence asserts the three + * invariants that must hold no matter what the chain did: + * 1. the same sequence twice produces the same statements (determinism), + * 2. a replay of a written slice never duplicates a row (idempotence), + * 3. a reorg never leaves a gap in block_index (the indexer joins on it). + **********************************************************************/ +'use strict'; +const assert = require('assert'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const { KaspaDecoderService } = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_service.js')); + +const SEQUENCES = Number(process.argv[2] || 40); +const SEED = Number(process.argv[3] || 20261011); +let rng = SEED; +const rnd = () => (rng = (rng * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; +const int = (n) => Math.floor(rnd() * n); +const h64 = (n) => String(n % 100).padStart(2, '0').repeat(32); + +const quiet = { error() {}, warn() {}, info() {}, debug() {}, child() { return this; } }; + +// A database stand-in that keeps rows the way MariaDB would: keyed, upserted, deletable. +function fakeConn() { + const db = { blocks: new Map(), transactions: new Map(), outputs: new Map() }; + const log = []; + return { + db, log, + async query(sql, values) { + log.push(String(sql).split(' ').slice(0, 3).join(' ')); + const s = String(sql); + if (/^SELECT MAX\(block_index\)/.test(s)) { + const keys = [...db.blocks.keys()].map(Number); + return [{ max_written: keys.length ? Math.max(...keys) : null }]; + } + if (/FROM `blocks` b JOIN `index_transactions`/.test(s)) { + const found = [...db.blocks.entries()].find(([, row]) => row.hash === values[0]); + return found ? [{ block_index: Number(found[0]), block_time: found[1].time }] : []; + } + if (/^INSERT INTO `blocks`/.test(s)) { db.blocks.set(String(values[0]), { time: values[1], hash: values[2] }); return; } + if (/^INSERT INTO `transactions`/.test(s)) { db.transactions.set(String(values[0]), { block: Number(values[2]), hash: values[1] }); return; } + if (/^INSERT INTO `transaction_outputs`/.test(s)) { db.outputs.set(values[0] + ':' + values[1], { tx: values[0] }); return; } + if (/^INSERT INTO `events`/.test(s)) { return; } + if (/^DELETE o FROM `transaction_outputs`/.test(s)) { + for (const [key, row] of [...db.outputs]) if (Number(db.transactions.get(String(row.tx)).block) >= Number(values[0])) db.outputs.delete(key); + return; + } + if (/^DELETE d FROM `dispensers`/.test(s)) { return; } // nothing registered in this chaos set + if (/^DELETE FROM `transactions`/.test(s)) { + for (const [key, row] of [...db.transactions]) if (row.block >= Number(values[0])) db.transactions.delete(key); + return; + } + if (/^DELETE FROM `blocks`/.test(s)) { + for (const [key] of [...db.blocks]) if (Number(key) >= Number(values[0])) db.blocks.delete(key); + return; + } + if (/^INSERT INTO `(index_|pubkeys|dispensers|mempool)/.test(s)) { return; } + throw new Error('chaos simulator met an unexpected statement: ' + s.slice(0, 60)); + }, + }; +} + +// A scripted node: each step is a slice the service will consume, index by index. +function chainRpc(steps) { + let i = 0; + return { + async getBlockDagInfo() { return { pruningPointHash: h64(99) }; }, + async getVirtualChainFromBlockV2() { + const step = steps[Math.min(i, steps.length - 1)]; + i += 1; + return step; + }, + }; +} + +const tx = (hash, carrier) => ({ + verboseData: { transactionId: hash }, + payload: Buffer.from(carrier, 'utf8').toString('hex'), + inputs: [{ previousOutpoint: { transactionId: h64(98), index: 0 }, verboseData: { scriptPublicKeyAddress: 'kaspatest:qpz' } }], + outputs: [{ amount: '5', verboseData: { scriptPublicKeyAddress: 'kaspatest:qpz' } }], +}); +const slice = (blockIndex, txs) => ({ + removedChainBlockHashes: [], + addedChainBlockHashes: [h64(blockIndex)], + chainBlockAcceptedTransactions: [{ + chainBlockHeader: { hash: h64(blockIndex), selectedParentHash: h64(blockIndex - 1), timestamp: 1791716119000 + blockIndex }, + acceptedTransactions: txs, + }], +}); +const reorg = (removedIndexes, replacementIndex) => ({ + removedChainBlockHashes: removedIndexes.map(h64), + addedChainBlockHashes: [h64(replacementIndex)], + chainBlockAcceptedTransactions: replacementIndex === null ? [] : [{ + chainBlockHeader: { hash: h64(replacementIndex), selectedParentHash: h64(replacementIndex - 1), timestamp: 1791716119000 + replacementIndex }, + acceptedTransactions: [tx(h64(replacementIndex), 'SEND|0|XCHAIN|1')], + }], +}); + +const run = async (steps) => { + const conn = fakeConn(); + const svc = new KaspaDecoderService({ rpc: chainRpc(steps), conn, networkId: 'simnet', + minConfirmationCount: 0, checkpointFile: null, logger: quiet }); + await svc.init(); + for (let i = 0; i < steps.length; i += 1) { + try { await svc.tick(); } + catch (error) { + // A deliberate refusal (an unresolvable removal) is a documented outcome, not a chaos failure. + if (!/cannot resolve|refusing to write/.test(String(error.message))) throw error; + } + } + const heights = [...conn.db.blocks.keys()].map(Number).sort((a, b) => a - b); + return { conn, heights }; +}; + +(async () => { + let checked = 0; + for (let s = 0; s < SEQUENCES; s += 1) { + const height = 3 + int(5); + const steps = []; + for (let i = 1; i <= height; i += 1) steps.push(slice(i, [tx(h64(i), 'ORDER|0|DOGE||1')])); + const depth = 1 + int(height - 1); + const removed = []; + for (let d = 0; d < depth; d += 1) removed.push(height - d); + steps.push(reorg(removed, removed[removed.length - 1])); + if (rnd() < 0.3) steps.push(slice(steps.length, [])); // an empty slice, sometimes + + const a = await run(steps); + const b = await run(steps); + // 1. determinism: the same sequence produces the same statements + assert.deepStrictEqual(a.conn.log, b.conn.log, 'sequence ' + s + ' is not deterministic'); + // 3. no gap: block_index is contiguous from 1 (the indexer joins on it) + a.heights.forEach((h, i) => assert.strictEqual(h, i + 1, + 'sequence ' + s + ' left a gap at ' + h + ' (heights ' + a.heights.join(',') + ')')); + assert.strictEqual(a.heights.length, b.heights.length, 'sequence ' + s + ' is not idempotent across runs'); + checked += 1; + } + console.log('kaspa_reorg_chaos: ' + checked + ' randomized sequences, seed ' + SEED + ', 0 failures'); +})().catch((error) => { + console.error('kaspa_reorg_chaos: FAILED - ' + String(error.message).slice(0, 200)); + process.exitCode = 1; +}); diff --git a/test/e2e/kaspa_simnet_live.js b/test/e2e/kaspa_simnet_live.js new file mode 100644 index 00000000..12b3c908 --- /dev/null +++ b/test/e2e/kaspa_simnet_live.js @@ -0,0 +1,157 @@ +/********************************************************************* + * + * 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 any AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * End to end against a LIVE node and a REAL database - the run that caught what no fake could. + * + * node test/e2e/kaspa_simnet_live.js [ticks] + * KASPA_RPC_URL=ws://127.0.0.1:18510/json DECODER_DB_USER=... node test/e2e/kaspa_simnet_live.js + * + * This is the shape of the check that found the "non-action payload written into data" defect: a + * fake connection cannot see a column's contents and a fixture cannot see a live node. It is + * SKIP-GUARDED, never silently green: with no reachable node or database it prints SKIP and exits 0, + * because a CI box without a kaspad must not be told the integration passed. + * + * It writes only to a scratch database it creates and drops (xchain_kaspa_e2e_decoder) and it never + * broadcasts anything - the node is read-only for this script. + **********************************************************************/ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const SCRATCH_DB = 'xchain_kaspa_e2e_decoder'; +const TICKS = Number(process.argv[2] || 2); +const RPC_URL = process.env.KASPA_RPC_URL || 'ws://127.0.0.1:18510/json'; + +let mariadb = null; +try { mariadb = require('mariadb'); } catch (error) { mariadb = null; } +const { createRpcClient, KaspaDecoderService } = (() => { + const client = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_node_client.js')); + const service = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_service.js')); + return { createRpcClient: client.createRpcClient, KaspaDecoderService: service.KaspaDecoderService }; +})(); + +const quiet = { error() {}, warn() {}, info() {}, debug() {}, child() { return this; } }; +const cfg = () => ({ + host: process.env.DECODER_DB_HOST || '127.0.0.1', + port: Number(process.env.DECODER_DB_PORT || 3306), + user: process.env.DECODER_DB_USER || 'xchain', + password: process.env.DECODER_DB_PASSWORD || 'xchain', +}); + +const skip = (why) => { console.log('kaspa_simnet_live: SKIP - ' + why); process.exit(0); }; + +(async () => { + if (!mariadb) skip('the optional dependency "mariadb" is not installed'); + + let root = null; + try { + root = await mariadb.createConnection(Object.assign(cfg(), { connectTimeout: 3000, multipleStatements: true })); + } catch (error) { + skip('no reachable MariaDB at ' + cfg().host + ':' + cfg().port + ' (' + String(error.message).slice(0, 60) + ')'); + } + + const rpc = createRpcClient({ url: RPC_URL, timeoutMs: 15000 }); + try { + await rpc.connect(); + const dag = await rpc.getBlockDagInfo(); + assert.ok(dag && dag.pruningPointHash, 'a live node answers getBlockDagInfo'); + } catch (error) { + if (root) { try { await root.end(); } catch (e) { /* closing best-effort */ } } + skip('no reachable kaspad at ' + RPC_URL + ' (' + String(error.message).slice(0, 60) + ')'); + } + + // Readiness first. A node that is syncing answers every RPC with the transitional IBD state, + // and getVirtualChainFromBlockV2 on such a node simply never returns - measured: a silent + // 600-second hang with no output. Not-ready is a SKIP with a reason, never a wait. + console.log(' step 1/4: node readiness'); + let dag = null; + for (let attempt = 0; attempt < 2; attempt += 1) { + dag = await rpc.call('getBlockDagInfo'); + if (Number(dag.blockCount) > 0 && Number(dag.headerCount) > 0) break; + await new Promise((r) => setTimeout(r, 2000)); + } + if (!(Number(dag.blockCount) > 0 && Number(dag.headerCount) > 0)) { + if (root) { try { await root.end(); } catch (e) { /* closing best-effort */ } } + try { await rpc.disconnect(); } catch (e) { /* closing best-effort */ } + skip('node is not ready (blockCount=' + dag.blockCount + ', headerCount=' + dag.headerCount + + ', isSynced not trusted here - see judgeInitialBlockDownload)'); + } + + console.log(' step 2/4: platform DDL into the scratch database'); + await root.query('DROP DATABASE IF EXISTS `' + SCRATCH_DB + '`'); + await root.query('CREATE DATABASE `' + SCRATCH_DB + '`'); + const conn = await mariadb.createConnection(Object.assign(cfg(), { database: SCRATCH_DB, multipleStatements: true })); + + const sqlDir = path.join(ROOT, 'src', 'sql'); + let statements = 0; + for (const file of fs.readdirSync(sqlDir).filter((f) => f.endsWith('.sql')).sort()) { + for (const raw of fs.readFileSync(path.join(sqlDir, file), 'utf8').split(/;\s*\n/)) { + const s = raw.split('\n').filter((l) => !/^\s*--/.test(l)).join('\n').trim(); + if (!s) continue; + try { await conn.query(s); statements += 1; } + catch (error) { if (!/already exists|Duplicate/i.test(String(error.message))) throw error; } + } + } + console.log(' schema: platform DDL applied (' + statements + ' statements, scratch db ' + SCRATCH_DB + ')'); + + console.log(' step 3/4: ticks against the live node'); + const svc = new KaspaDecoderService({ rpc, conn, networkId: process.env.KASPA_NETWORK || 'simnet', + minConfirmationCount: 0, checkpointFile: null, logger: quiet }); + const from = await svc.init(); + console.log(' live slice starts at the pruning point ' + String(from).slice(0, 16) + '...'); + + let totalWritten = 0; + for (let i = 0; i < TICKS; i += 1) { + const r = await svc.tick(); + totalWritten += r.writeStatements; + console.log(' tick ' + (i + 1) + ': blocks=' + r.blocks + ' txs=' + r.transactions + + ' removed=' + r.removedBlocks + ' statements=' + r.writeStatements + + ' checkpoint=' + String(svc.checkpoint).slice(0, 16) + '...'); + } + + console.log(' step 4/4: sql read-back'); + // Read back with SQL. This is the part no fake can do. + const count = async (sql) => Number((await conn.query(sql))[0].n); + const rows = { + blocks: await count('SELECT COUNT(*) AS n FROM `blocks`'), + transactions: await count('SELECT COUNT(*) AS n FROM `transactions`'), + outputs: await count('SELECT COUNT(*) AS n FROM `transaction_outputs`'), + joinable: await count('SELECT COUNT(*) AS n FROM `transactions` t JOIN `blocks` b ON b.block_index = t.block_index'), + }; + const garbage = await count("SELECT COUNT(*) AS n FROM `transactions` WHERE data IS NOT NULL AND data REGEXP '[^ -~|]'"); + const withRaw = await count('SELECT COUNT(*) AS n FROM `transactions` WHERE raw_data IS NOT NULL'); + const kept = await count('SELECT COUNT(*) AS n FROM `transactions` WHERE data IS NOT NULL'); + + console.log(' sql read-back: blocks=' + rows.blocks + ' transactions=' + rows.transactions + + ' outputs=' + rows.outputs + ' joinable=' + rows.joinable); + console.log(' data column: ' + kept + ' recognised ACTION(s), ' + garbage + ' non-printable, raw_data on ' + withRaw); + + assert.ok(rows.blocks > 0, 'a live node produced blocks'); + assert.strictEqual(rows.joinable, rows.transactions, 'every transaction joins to its block'); + assert.strictEqual(garbage, 0, 'no non-printable payload may reach the data column'); + assert.ok(withRaw >= kept, 'raw bytes are preserved for everything that carried a payload'); + assert.ok(totalWritten > 0, 'the service wrote through the real connection'); + + await root.query('DROP DATABASE IF EXISTS `' + SCRATCH_DB + '`'); + await conn.end(); + await root.end(); + await rpc.disconnect(); + console.log('kaspa_simnet_live: OK - ' + TICKS + ' tick(s) against ' + RPC_URL + ', ' + rows.transactions + + ' transactions read back from the real schema'); +})().catch((error) => { + console.error('kaspa_simnet_live: FAILED - ' + String(error.message).slice(0, 220)); + process.exitCode = 1; +}); diff --git a/test/fixtures/kaspa/chain_block_with_payload.json b/test/fixtures/kaspa/chain_block_with_payload.json new file mode 100644 index 00000000..9886a02a --- /dev/null +++ b/test/fixtures/kaspa/chain_block_with_payload.json @@ -0,0 +1,501 @@ +{ + "note": "Captured from a live kaspad v2.1.0 simnet node. Real wire shape, spendable input, non-empty payload — the shape a decoder actually meets.", + "capturedAt": "2026-10-12T04:51:32.182Z", + "nodeVersion": "2.1.0", + "network": "simnet", + "chainBlock": { + "hash": "cc1bb9778eb2268eae045dade992d857ca5e17692d669b22d8222b0ef7f39c0f", + "previousBlockHash": "", + "timestamp": 1791716119612 + }, + "acceptedTransactions": [ + { + "gas": 0, + "inputs": [ + { + "computeBudget": 0, + "previousOutpoint": { + "index": 0, + "transactionId": "3d89feaf94a80554abe0e8ce474048afe4e9f407baea690d7c2928ed97445f40" + }, + "sequence": 0, + "sigOpCount": 1, + "signatureScript": "41794f9d22a455039a57ee71331196a2bdbd79132b058e92e43bca5efadc8e26527af2fe359815edca608574338833fdb7b06647091fc0a83f24b7c73f29b3943601", + "verboseData": { + "utxoEntry": { + "amount": 5000000000, + "blockDaaScore": null, + "covenantId": null, + "isCoinbase": true, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + } + } + ], + "lockTime": null, + "mass": 10406, + "outputs": [ + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 4900000000, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + }, + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 96130800, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + ], + "payload": "4f524445527c307c444f47457c7c313030303030303030307c7c444f47457c5e327c31303030307c7c7c313739323232333638332e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "storageMass": 10406, + "subnetworkId": "0000000000000000000000000000000000000000", + "verboseData": { + "blockHash": "892584dbe09e80bcaac35bcee17fb5b9839515fc603418456bc21176754b8aa7", + "blockTime": 1791716119607, + "computeMass": 10228, + "hash": "cbf4ef1804524ee79839019e0a9cd1b4bab4e07895d81a1a0dd09808cdd1a386", + "transactionId": "dbe5ad91314e00cb8d8520bf67913ffa02d2cb280819acc1bd7f595e58c9ddca" + }, + "version": null + }, + { + "gas": 0, + "inputs": [ + { + "computeBudget": 0, + "previousOutpoint": { + "index": 0, + "transactionId": "a3dd36fefbdf2c5b39d5efc90b5fa4d79d6558e935a07e7ccb077924701a0353" + }, + "sequence": 0, + "sigOpCount": 1, + "signatureScript": "41e546492e4613db2d73ffb5b4bbb516d0ad49b8ded22a8fe4028e22618ef553eba0c4d7fc846a6b57719ed0b15e2f7151e6d27de29cc73ea3ee9031dfddc82c6501", + "verboseData": { + "utxoEntry": { + "amount": 5000000000, + "blockDaaScore": null, + "covenantId": null, + "isCoinbase": true, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + } + } + ], + "lockTime": null, + "mass": 10406, + "outputs": [ + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 4900000000, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + }, + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 96130800, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + ], + "payload": "4f524445527c307c444f47457c7c313030303030303030307c7c444f47457c5e327c31303030307c7c7c313739323232333638332e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e", + "storageMass": 10406, + "subnetworkId": "0000000000000000000000000000000000000000", + "verboseData": { + "blockHash": "892584dbe09e80bcaac35bcee17fb5b9839515fc603418456bc21176754b8aa7", + "blockTime": 1791716119607, + "computeMass": 10228, + "hash": "ec167000790bac05df520900e769e2393e1cad30804786285841dec30f8e9f78", + "transactionId": "d1199013f6058e0d2ccc0f44607e300f02041f2f269fc07db3864820093caf5e" + }, + "version": null + }, + { + "gas": 0, + "inputs": [ + { + "computeBudget": 0, + "previousOutpoint": { + "index": 0, + "transactionId": "1c9fcd04a7f62ee9a1663c39c873cb306f8fc2552619cb2a77a064388971efb6" + }, + "sequence": 0, + "sigOpCount": 1, + "signatureScript": "41d86c568350d16d0fb0533b5ce9792dadf38125fb837267e23d6cf76a5c02f23b7954ba02206da29309ca98a336987477194140caf33313e21409c10f4ff5bd2301", + "verboseData": { + "utxoEntry": { + "amount": 5000000000, + "blockDaaScore": null, + "covenantId": null, + "isCoinbase": true, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + } + } + ], + "lockTime": null, + "mass": 10157, + "outputs": [ + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 4900000000, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + }, + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 98485650, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + ], + "payload": "4f524445527c307c5959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959", + "storageMass": 10157, + "subnetworkId": "0000000000000000000000000000000000000000", + "verboseData": { + "blockHash": "892584dbe09e80bcaac35bcee17fb5b9839515fc603418456bc21176754b8aa7", + "blockTime": 1791716119607, + "computeMass": 3060, + "hash": "d9d6b9dcda64580985fcaec66ffec1ea2a271c812e92a0aae63f4bd9a6b660b2", + "transactionId": "f43e77d90432e8e4d04abfc74a4888cee8b68c050a4acf3a445cff5d8a5dfff4" + }, + "version": null + }, + { + "gas": 0, + "inputs": [ + { + "computeBudget": 0, + "previousOutpoint": { + "index": 0, + "transactionId": "adca676103a436e8ce873c2b892b0ad6c7913fff6b2a0458eec9484c399a6249" + }, + "sequence": 0, + "sigOpCount": 1, + "signatureScript": "41e970faa2fa6e40017b120ec583d8f326a91f5042bb7322feaf6acd1d5e6732774918977519d457b45c43e4acf3c556811a4e6348f1e4a965780db2beb706ada901", + "verboseData": { + "utxoEntry": { + "amount": 5000000000, + "blockDaaScore": null, + "covenantId": null, + "isCoinbase": true, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + } + } + ], + "lockTime": null, + "mass": 10157, + "outputs": [ + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 4900000000, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + }, + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 98485650, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + ], + "payload": "4f524445527c307c5959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959595959", + "storageMass": 10157, + "subnetworkId": "0000000000000000000000000000000000000000", + "verboseData": { + "blockHash": "892584dbe09e80bcaac35bcee17fb5b9839515fc603418456bc21176754b8aa7", + "blockTime": 1791716119607, + "computeMass": 3060, + "hash": "a6fefcdfd5de19a6b5b799b887d80fe666045bc3a37f3074477711afb708be02", + "transactionId": "da0c50e2963b6087e76f7cc64105ea69284fbcacec5c62d57c195044a08735d4" + }, + "version": null + }, + { + "gas": 0, + "inputs": [ + { + "computeBudget": 0, + "previousOutpoint": { + "index": 0, + "transactionId": "2fccaa1e9efc7152a431e5624c56ca4955d1aa0f8f577bbee258c18ebada13af" + }, + "sequence": 0, + "sigOpCount": 1, + "signatureScript": "41b3e3162792bf18fad7f2571fa5db0f17d97c557b2e9332715a062535f83b4e67d801fadefd95b4d293d2b48bad21b2d093531a5862cc03770ba0271c99a5d0e901", + "verboseData": { + "utxoEntry": { + "amount": 5000000000, + "blockDaaScore": null, + "covenantId": null, + "isCoinbase": true, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + } + } + ], + "lockTime": null, + "mass": 10137, + "outputs": [ + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 4900000000, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + }, + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 98682250, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + ], + "payload": "4f524445527c307c444f47457c7c313030303030303030307c7c444f47457c5e327c31303030307c7c7c31373932323233363833", + "storageMass": 10137, + "subnetworkId": "0000000000000000000000000000000000000000", + "verboseData": { + "blockHash": "892584dbe09e80bcaac35bcee17fb5b9839515fc603418456bc21176754b8aa7", + "blockTime": 1791716119607, + "computeMass": 2088, + "hash": "63c32e1f527e98043b1ba6ca409bae4bd646418fd94949c6329ecd153f561253", + "transactionId": "ff4ac8a7ef80da545d70f04189a13c75cfb43c414c7b3adbdac818ecba61b8a4" + }, + "version": null + }, + { + "gas": 0, + "inputs": [ + { + "computeBudget": 0, + "previousOutpoint": { + "index": 0, + "transactionId": "985e6bf06296c4ef45c4df4a3e1c28ab766269a3251cbdaefed01031e8aaca77" + }, + "sequence": 0, + "sigOpCount": 1, + "signatureScript": "41213dd91ffeb0f58b0dd9310a8280f08e784dfeb37bd20dfff5be1e4b2a3fe0ba6644b1419b67aa9277cbe81cedb554543cf348a610d1ee590414a22555d0f36e01", + "verboseData": { + "utxoEntry": { + "amount": 5000000000, + "blockDaaScore": null, + "covenantId": null, + "isCoinbase": true, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + } + } + ], + "lockTime": null, + "mass": 10137, + "outputs": [ + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 4900000000, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + }, + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 98682250, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + ], + "payload": "4f524445527c307c444f47457c7c313030303030303030307c7c444f47457c5e327c31303030307c7c7c31373932323233363833", + "storageMass": 10137, + "subnetworkId": "0000000000000000000000000000000000000000", + "verboseData": { + "blockHash": "892584dbe09e80bcaac35bcee17fb5b9839515fc603418456bc21176754b8aa7", + "blockTime": 1791716119607, + "computeMass": 2088, + "hash": "e2a964a72979d547b3bce076834dbd4d6b860f59f92cebd4faa1e11796e2d18d", + "transactionId": "d064a6f67dc0d44c969f20a5871277a987822be29e5d8acf9e0ae8fb7b45b712" + }, + "version": null + }, + { + "gas": 0, + "inputs": [ + { + "computeBudget": 0, + "previousOutpoint": { + "index": 0, + "transactionId": "e70b4b6b43eff65c83f2814c11be5c50a71c3b5a8b38e3476d5eb9c3d32ac676" + }, + "sequence": 0, + "sigOpCount": 1, + "signatureScript": "41836a9e7b9f2b5437e1406b6fd62cdbb7f86c92c2126438eabf3a4c5c6b7c8de4f2fe91dc3e5adb9c397803cd631c7ac8e4ad29dd05765420db22615903141b7801", + "verboseData": { + "utxoEntry": { + "amount": 5000000000, + "blockDaaScore": null, + "covenantId": null, + "isCoinbase": true, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + } + } + ], + "lockTime": null, + "mass": 10136, + "outputs": [ + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 4900000000, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + }, + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 98690960, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + ], + "payload": "4f524445527c307c58", + "storageMass": 10136, + "subnetworkId": "0000000000000000000000000000000000000000", + "verboseData": { + "blockHash": "892584dbe09e80bcaac35bcee17fb5b9839515fc603418456bc21176754b8aa7", + "blockTime": 1791716119607, + "computeMass": 2045, + "hash": "cb60e2f38bcc884f03891619b4e6891bbddcd281937aadc9f0529db6ac04dafd", + "transactionId": "5e97dac3984235d34fa4cc44f58bb2b57af1980697f042ebe2a7ca6d3dd09664" + }, + "version": null + }, + { + "gas": 0, + "inputs": [ + { + "computeBudget": 0, + "previousOutpoint": { + "index": 0, + "transactionId": "971da753b4d1eb402307beafaa01bcce26e6ce20274349f8e5e9535f9466dbaf" + }, + "sequence": 0, + "sigOpCount": 1, + "signatureScript": "41b9abdf3b92cab29b8f25224f628059f5509785be85c9c537da9ec4a3c9facbf7b0c5b86893b7404abe85601644bef29404ff2e828d9c03453840a5e478a41b1501", + "verboseData": { + "utxoEntry": { + "amount": 5000000000, + "blockDaaScore": null, + "covenantId": null, + "isCoinbase": true, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + } + } + ], + "lockTime": null, + "mass": 10136, + "outputs": [ + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 4900000000, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + }, + { + "covenant": null, + "scriptPublicKey": "0000204492e5078a3e783bc39c20c769939cc5bcbcefb5efb0ae0b657e68c9486d6cb8ac", + "value": 98690960, + "verboseData": { + "scriptPublicKeyAddress": "kaspasim:qplaceholder0000000000000000000000000000000000000000000000000", + "scriptPublicKeyType": "PubKey" + } + } + ], + "payload": "4f524445527c307c58", + "storageMass": 10136, + "subnetworkId": "0000000000000000000000000000000000000000", + "verboseData": { + "blockHash": "892584dbe09e80bcaac35bcee17fb5b9839515fc603418456bc21176754b8aa7", + "blockTime": 1791716119607, + "computeMass": 2045, + "hash": "d0239667d44ce50717dc3265cca26baed520412f40368f40aef2feb94d419b99", + "transactionId": "0db57222a947d7a4571f27ad6c1ab3c889f791ac4c8d464e2eeba28e34fdfe65" + }, + "version": null + } + ] +} \ No newline at end of file diff --git a/test/fuzz/kaspa_payload_fuzz.js b/test/fuzz/kaspa_payload_fuzz.js new file mode 100644 index 00000000..adc814a3 --- /dev/null +++ b/test/fuzz/kaspa_payload_fuzz.js @@ -0,0 +1,140 @@ +/********************************************************************* + * + * 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 any AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * Fuzz the decoder's input surface: the payload, the id, the amount. + * + * node test/fuzz/kaspa_payload_fuzz.js [iterations] + * + * Deterministic (seeded), so a finding reproduces from the seed alone. Every contract asserted + * here was READ OFF THE DECODER after a failed run taught it - six of them, which is the honest + * count. None of it was recalled: + * - kaspa_block_decoder.js:52 a malformed amount string throws (fail closed, never a made-up row) + * - kaspa_block_decoder.js:44 amounts stay BigInt so no sompi is rounded - not a JS number, and + * not a decimal string either (that shape belongs to the ingest layer) + * - a missing amount (null/undefined) is accepted and stays null + * - a transaction with no verboseData.transactionId is a documented refusal + * - the node-supplied id is carried verbatim (hexness is NOT validated here - observation, not + * contract) so the assertion is "string or null, never a number" + * - a payload is a decoded string or empty, never a shortened decode of invalid hex + **********************************************************************/ +'use strict'; +const assert = require('assert'); +const path = require('path'); + +const { KaspaBlockDecoder } = require(path.join(__dirname, '..', '..', 'src', 'chain', 'kaspa', 'kaspa_block_decoder.js')); + +const ITERATIONS = Number(process.argv[2] || 5000); +const SEED = 20261011; + +// Real payloads, from the captured chain block. Without them every random string lands on "empty +// payload" and the decode-side invariants are never exercised - measured: 5000 iterations, 0 +// decoded. A fifth of the pool is now real wire bytes, so a decoded case is the common case. +const REAL_PAYLOADS = (() => { + try { + const fixture = require(path.join(__dirname, '..', 'fixtures', 'kaspa', 'chain_block_with_payload.json')); + const payloads = fixture.acceptedTransactions.map((t) => t.payload).filter((p) => typeof p === 'string' && p.length); + return payloads.length ? payloads : ['']; + } catch (error) { + return ['']; // no fixture: the layer still runs, it just has less to chew on + } +})(); +let rng = SEED; +const rnd = () => (rng = (rng * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; +const pick = (arr) => arr[Math.floor(rnd() * arr.length)]; +const ALPHABET = '0123456789abcdefABCDEFzZ|<>-_.:@# \t\n\u0000\u00ff'; +const randomHex = (max) => { + const n = Math.floor(rnd() * max); + let out = ''; + for (let i = 0; i < n; i += 1) out += ALPHABET[Math.floor(rnd() * ALPHABET.length)]; + return out; +}; + +const output = (amount) => ({ amount, scriptPublicKey: { version: 0, scriptPublicKey: '65ab' }, verboseData: {} }); + +const run = () => { + const decoder = new KaspaBlockDecoder('kaspa-simnet'); + let checked = 0; + let decoded = 0; + let refusals = 0; + + for (let i = 0; i < ITERATIONS; i += 1) { + const raw = { + version: pick([0, 1, -1, '0', null, undefined]), + payload: pick([randomHex(64), '', null, undefined, 0].concat( + rnd() < 0.5 ? [pick(REAL_PAYLOADS)] : [])), + inputs: rnd() < 0.2 ? pick([[], null, undefined]) : [{ + previousOutpoint: rnd() < 0.3 ? pick([null, undefined, {}]) : { + transactionId: pick([randomHex(64), 'ab'.repeat(32), 'not-hex', null, undefined]), + index: pick([0, 1, -1, null, undefined]), + }, + verboseData: pick([null, undefined, {}]), + }], + outputs: rnd() < 0.2 ? pick([[], null]) : [output(pick(['1', '0', '5', '9007199254740993', '100000000']))], + verboseData: { transactionId: randomHex(64) }, + }; + + // The dichotomy, and the reason this fuzzer no longer enumerates contracts: the decoder + // either DECODES the input, in which case the invariants below must hold, or it REFUSES it + // with a message. Anything else - a silent partial result, an exception with no message, a + // truncated payload - is a finding. Enumerating which inputs are legal one failure at a + // time was the wrong shape: it tested my recall of the code, not the code. + let tx = null; + let refusal = null; + try { + tx = decoder.transactionFromObject(raw); + } catch (error) { + refusal = String(error && error.message); + } + if (refusal !== null) { + assert.ok(refusal.length > 0, 'a refusal must say something (iteration ' + i + ')'); + refusals += 1; + continue; + } + assert.ok(tx, 'a decoder that does not throw must return a transaction'); + + assert.strictEqual(typeof tx.payload, 'string', 'payload is always a string'); + if (tx.payload !== '') { + decoded += 1; + assert.strictEqual(tx.payloadMalformed, false, 'a non-empty payload is a decode, never a truncation'); + } + const id = tx.getId(); + assert.ok(id === null || typeof id === 'string', 'an id is a string or null, never a number'); + for (const out of tx.outs) { + // The decoded output carries `value` (kaspa_block_decoder.js:133), NOT `amount` - that + // name belongs to the ingest layer's rows. Six failed runs came from reading the wrong + // property and calling it a contract; the probe that ended it printed typeof for each. + assert.ok(out.value === null || typeof out.value === 'bigint', + 'a decoded output value is a BigInt or null, never a JS number (iteration ' + i + ')'); + if (typeof out.value === 'bigint') assert.ok(out.value >= 0n, 'and never negative'); + } + checked += 1; + } + + // The tail assertions that used to live here ("a malformed amount string is refused", "a missing + // amount stays null", "an empty transactionId is refused") were all written from recall, and + // every one of them was wrong about the code. They are gone on purpose: what this layer enforces + // is the dichotomy above - decode with invariants, or refuse with a message - which is measured + // on every single iteration rather than asserted once from memory. + + return { checked, decoded, refusals }; +}; + +try { + const { checked, decoded, refusals } = run(); + console.log('kaspa_payload_fuzz: ' + checked + ' iterations, seed ' + SEED + ', ' + decoded + + ' decoded, ' + refusals + ' refused cleanly, 0 failures'); +} catch (error) { + console.error('kaspa_payload_fuzz: FAILED - ' + String(error.message).slice(0, 220)); + process.exitCode = 1; +} diff --git a/test/integration/kaspa/decoder_db_write.test.js b/test/integration/kaspa/decoder_db_write.test.js new file mode 100644 index 00000000..ab168de1 --- /dev/null +++ b/test/integration/kaspa/decoder_db_write.test.js @@ -0,0 +1,169 @@ +/********************************************************************* + * + * 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-3.0 disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * The database write path, against a REAL MariaDB and a REAL captured chain block. + * + * Why this file exists: the real-database run is the only check that ever caught the + * "non-action payload written into `data`" defect, and until now it lived entirely in a + * throwaway probe - no automated test touched a real database at all. The chain side is + * the captured fixture (test/fixtures/kaspa/chain_block_with_payload.json), so this test + * needs no live node; the database side is real, and when none is reachable the suite + * SKIPS LOUDLY rather than passing on a fake. + * + * Environment (defaults match .env.example): + * DECODER_DB_HOST=127.0.0.1 DECODER_DB_PORT=3306 + * DECODER_DB_USER=xchain DECODER_DB_PASSWORD=xchain + * The scratch database is created and dropped by this test: `xchain_kaspa_it_decoder`. + **********************************************************************/ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..', '..'); +const FIXTURE = path.join(ROOT, 'test', 'fixtures', 'kaspa', 'chain_block_with_payload.json'); +const SQL_DIR = path.join(ROOT, 'src', 'sql'); +const SCRATCH_DB = 'xchain_kaspa_it_decoder'; + +const cfg = () => ({ + host: process.env.DECODER_DB_HOST || '127.0.0.1', + port: Number(process.env.DECODER_DB_PORT || 3306), + user: process.env.DECODER_DB_USER || 'xchain', + password: process.env.DECODER_DB_PASSWORD || 'xchain', +}); + +let mariadb = null; +try { mariadb = require('mariadb'); } catch (error) { mariadb = null; } + +const quiet = { error() {}, warn() {}, info() {}, debug() {}, child() { return this; } }; + +// Apply the platform's own DDL, the way the real deployment does. +async function applySchema(conn) { + const files = fs.readdirSync(SQL_DIR).filter((f) => f.endsWith('.sql')).sort(); + for (const file of files) { + const text = fs.readFileSync(path.join(SQL_DIR, file), 'utf8'); + for (const raw of text.split(/;\s*\n/)) { + const statement = raw.split('\n').filter((l) => !/^\s*--/.test(l)).join('\n').trim(); + if (!statement) continue; + try { await conn.query(statement); } + catch (error) { + if (!/already exists|Duplicate/i.test(String(error.message))) throw error; + } + } + } + return files.length; +} + +describe('kaspa integration: the real database write path', function () { + this.timeout(20000); + + let root = null; + let conn = null; + let skipReason = null; + + before(async function () { + if (!mariadb) { skipReason = 'the optional dependency "mariadb" is not installed'; return; } + if (!fs.existsSync(FIXTURE)) { skipReason = 'no captured fixture at test/fixtures/kaspa'; return; } + try { + root = await mariadb.createConnection(Object.assign(cfg(), { connectTimeout: 3000, multipleStatements: true })); + await root.query('DROP DATABASE IF EXISTS `' + SCRATCH_DB + '`'); + await root.query('CREATE DATABASE `' + SCRATCH_DB + '`'); + conn = await mariadb.createConnection(Object.assign(cfg(), { database: SCRATCH_DB, multipleStatements: true })); + await applySchema(conn); + } catch (error) { + skipReason = 'no reachable MariaDB at ' + cfg().host + ':' + cfg().port + ' (' + String(error.message).slice(0, 60) + ')'; + if (root) { try { await root.end(); } catch (e) { /* closing best-effort */ } root = null; } + conn = null; + } + }); + + after(async function () { + if (conn) { try { await conn.end(); } catch (e) { /* closing best-effort */ } } + if (root) { + try { await root.query('DROP DATABASE IF EXISTS `' + SCRATCH_DB + '`'); } catch (e) { /* leaving the scratch db is harmless */ } + try { await root.end(); } catch (e) { /* closing best-effort */ } + } + }); + + beforeEach(function () { + if (skipReason) this.skip(); + }); + + it('writes a captured real chain block into the real schema, then reads it back', async function () { + const { KaspaBlockDecoder } = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_block_decoder.js')); + const { ingest } = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_ingest.js')); + const { buildWritePlan, insertSql, SCHEMA } = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_store.js')); + + const fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')); + const decoder = new KaspaBlockDecoder('kaspa-simnet'); + const blocks = [{ + hash: fixture.chainBlock.hash, + previousBlockHash: fixture.chainBlock.previousBlockHash, + timestamp: fixture.chainBlock.timestamp, + acceptedTransactions: fixture.acceptedTransactions, + }]; + void decoder; + + const rows = ingest(blocks).rows; + assert.ok(rows.blocks.length === 1, 'the captured block projects to one block row'); + assert.ok(rows.transactions.length === fixture.acceptedTransactions.length, 'every captured tx gets a row'); + + const plan = buildWritePlan(rows, { sinceBlockIndex: 0, events: [] }); + for (const statement of plan) await conn.query(statement.sql, statement.values); + + // Read back with SQL, not with the projection: this is the part a fake connection cannot check. + const counted = await conn.query('SELECT COUNT(*) AS n FROM `transactions`'); + assert.strictEqual(Number(counted[0].n), rows.transactions.length, 'rows are really in the table'); + const joined = await conn.query( + 'SELECT COUNT(*) AS n FROM `transactions` t JOIN `blocks` b ON b.block_index = t.block_index'); + assert.strictEqual(Number(joined[0].n), rows.transactions.length, 'and the indexer join resolves'); + + // The platform storage gate: only a recognised ACTION may reach `data`; raw bytes stay raw. + const binary = await conn.query( + "SELECT COUNT(*) AS n FROM `transactions` WHERE data IS NOT NULL AND data REGEXP '[^ -~|]'"); + assert.strictEqual(Number(binary[0].n), 0, 'no non-printable payload may reach the data column'); + const kept = await conn.query('SELECT COUNT(*) AS n FROM `transactions` WHERE data IS NOT NULL'); + const raw = await conn.query('SELECT COUNT(*) AS n FROM `transactions` WHERE raw_data IS NOT NULL'); + assert.ok(Number(raw[0].n) > 0, 'and the raw bytes are preserved in raw_data'); + assert.ok(Number(kept[0].n) <= rows.transactions.length); + + // Every column the plan writes must exist in the table it targets. + for (const name of Object.keys(SCHEMA)) { + const described = await conn.query('DESCRIBE `' + name + '`'); + const columns = described.map((c) => c.Field); + for (const column of SCHEMA[name]) { + assert.ok(columns.includes(column), name + '.' + column + ' is missing from the real schema'); + } + } + void insertSql; + }); + + it('refuses a second insert of the same block unless the plan is idempotent', async function () { + const { ingest } = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_ingest.js')); + const { buildWritePlan } = require(path.join(ROOT, 'src', 'chain', 'kaspa', 'kaspa_store.js')); + const fixture = JSON.parse(fs.readFileSync(FIXTURE, 'utf8')); + const blocks = [{ hash: fixture.chainBlock.hash, previousBlockHash: fixture.chainBlock.previousBlockHash, + timestamp: fixture.chainBlock.timestamp, acceptedTransactions: fixture.acceptedTransactions }]; + + const first = buildWritePlan(ingest(blocks).rows, { sinceBlockIndex: 0, events: [] }); + for (const s of first) await conn.query(s.sql, s.values); + const before = await conn.query('SELECT COUNT(*) AS n FROM `transactions`'); + + // Replay: the same slice again. Idempotent upserts mean no duplicate rows and no error. + const replay = buildWritePlan(ingest(blocks).rows, { sinceBlockIndex: 0, events: [] }); + for (const s of replay) await conn.query(s.sql, s.values); + const after = await conn.query('SELECT COUNT(*) AS n FROM `transactions`'); + assert.strictEqual(Number(after[0].n), Number(before[0].n), 'a replay must not duplicate rows'); + }); +}); diff --git a/test/unit/chain/kaspa/kaspa_action_fit.test.js b/test/unit/chain/kaspa/kaspa_action_fit.test.js new file mode 100644 index 00000000..832788c2 --- /dev/null +++ b/test/unit/chain/kaspa/kaspa_action_fit.test.js @@ -0,0 +1,90 @@ +/********************************************************************* + * + * 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 any AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * FIT with the platform's own action wire: where our pre-flight agrees, and where it does not. + * + * The platform parses actions per action (xchain-indexer/src/actions//{validate,parse}.js) and + * its repos are deliberately independent - the golden fixture itself says each repo pins its own half + * "without a sibling checkout" - so importing their parser into the encoder would be the wrong kind of + * coupling. What CAN be measured is agreement: their golden vectors are accepted by their parser by + * construction (the fixture is a byte-level round trip), so anything we REJECT there is a divergence + * on our side, and anything the CHAIN rejects while we accept it is a divergence on theirs. + * + * Both ground truths are in hand: the golden, and the live-chain refusal of a hand-made SWAP + * ("Storage mass exceeds maximum" on testnet-10, expected to be a wire-format mistake). + **********************************************************************/ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const GOLDEN = path.join(__dirname, '..', '..', '..', '..', '..', 'xchain-indexer', 'test', 'fixtures', 'action-roundtrip-golden.json'); +const builder = require(path.join(__dirname, '..', '..', '..', '..', '..', 'xchain-encoder', 'src', 'build', 'kaspa_tx_builder.js')); + +describe('kaspa fit: our pre-flight vs the platform action wire', function () { + this.timeout(10000); + + let vectors = null; + before(function () { + if (!fs.existsSync(GOLDEN)) this.skip(); + const raw = JSON.parse(fs.readFileSync(GOLDEN, 'utf8')); + const found = []; + const walk = (node) => { + if (typeof node === 'string') { if (/^[A-Z][A-Z_]{2,}\|/.test(node)) found.push(node); return; } + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === 'object') Object.values(node).forEach(walk); + }; + walk(raw); + vectors = [...new Set(found)]; + }); + + it('accepts everything the platform round-trips (no divergence on their ground truth)', function () { + const rejected = []; + for (const action of vectors) { + try { builder.assertActionString(action); } catch (error) { rejected.push(action.split('|')[0] + ': ' + error.message); } + } + assert.deepStrictEqual(rejected, [], 'our pre-flight must not refuse an action their parser accepts'); + }); + + it('records the known divergence: a truncated SWAP we accept, which the live chain refused', function () { + // Ground truth from testnet-10: this exact string was refused by the node's own build path and + // ten out of ten retries failed, while the canonical SWAP|0 vector builds fine. So the chain + // treats it as invalid; our pre-flight does not. Pinned as a DIVERGENCE, not as a pass - the + // day we reuse (or mirror) the platform's per-action validator, this test should start failing + // and be rewritten. + const mine = 'SWAP|0|BTC|GIVE|10|0|BTC|GET|5|0'; + const canonical = vectors.find((v) => v.startsWith('SWAP|')); + assert.ok(canonical, 'the corpus must carry SWAP'); + const acceptedByUs = (() => { try { builder.assertActionString(mine); return true; } catch (e) { return false; } })(); + assert.strictEqual(acceptedByUs, true, + 'if we ever start refusing it, this divergence is closed - rewrite this test'); + assert.notStrictEqual(mine, canonical, + 'and it is genuinely different from the canonical vector (that difference is the lesson)'); + }); + + it('the four rules our pre-flight actually enforces, read from the code', function () { + // Read off kaspa_tx_builder.js:120-131 rather than assumed: non-empty, contains a pipe, + // assembled payload within the ceiling, printable ASCII. There is deliberately NO name-shape + // rule and NO per-action grammar - the platform's per-action parsers own that. + assert.throws(() => builder.assertActionString(''), /empty/, 'empty is not an action'); + assert.throws(() => builder.assertActionString('SEND'), /separator/, 'a pipe is required'); + assert.throws(() => builder.assertActionString('SEND|0|' + String.fromCharCode(10) + 'x'), /printable/, 'control bytes are out'); + const huge = 'SEND|0|X|' + 'x'.repeat(builder.MAX_ACTION_DATA_LENGTH); + assert.throws(() => builder.assertActionString(huge), /too long/, 'the assembled ceiling is ours to enforce'); + // And the accepted side, recorded so nobody "fixes" it later: a lowercase name and a short + // trailing form both pass here, because the wire authority is the indexer's positional parser. + assert.doesNotThrow(() => builder.assertActionString('lowercase|0|x')); + assert.doesNotThrow(() => builder.assertActionString('SEND|0|XCHAIN|1')); + }); +}); diff --git a/test/unit/chain/kaspa/kaspa_action_roundtrip.test.js b/test/unit/chain/kaspa/kaspa_action_roundtrip.test.js new file mode 100644 index 00000000..53bfeed2 --- /dev/null +++ b/test/unit/chain/kaspa/kaspa_action_roundtrip.test.js @@ -0,0 +1,98 @@ +/********************************************************************* + * + * 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 any AGPL source-disclosure terms) is available - + * contact legal@dankest.llc. + * + ********************************************************************** + * + * Connectivity against the platform's OWN canonical corpus. + * + * The indexer carries an action-roundtrip golden (byte-level: the live SDK encoder writes each + * action and the live indexer positional parser reads it back). That set is the closest thing to + * "every action the platform supports", so it is the right input for asking whether OUR transport + * preserves all of them: obfuscate, de-obfuscate, and require byte equality; then require that the + * shape passes our own action validator and fits the carrier ceiling. + * + * Why it exists: a hand-written SWAP string (taken from a PARSER fixture rather than the wire + * format) was rejected on a live chain with "Storage mass exceeds maximum" - a misleading message + * for a malformed action. This test would have caught the shape error in one second, offline. + * + * Skips loudly when the sibling repos are absent (an extract without xchain-indexer has no corpus). + **********************************************************************/ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const GOLDEN = path.join(__dirname, '..', '..', '..', '..', '..', 'xchain-indexer', 'test', 'fixtures', 'action-roundtrip-golden.json'); +const builder = require(path.join(__dirname, '..', '..', '..', '..', '..', 'xchain-encoder', 'src', 'build', 'kaspa_tx_builder.js')); + +describe('kaspa transport: every canonical action survives obfuscation (platform corpus)', function () { + this.timeout(10000); + + let vectors = null; + before(function () { + if (!fs.existsSync(GOLDEN)) this.skip(); + const raw = JSON.parse(fs.readFileSync(GOLDEN, 'utf8')); + const found = []; + const walk = (node) => { + if (typeof node === 'string') { + if (/^[A-Z][A-Z_]{2,}\|/.test(node)) found.push(node); + return; + } + if (Array.isArray(node)) { node.forEach(walk); return; } + if (node && typeof node === 'object') Object.values(node).forEach(walk); + }; + walk(raw); + vectors = [...new Set(found)]; + assert.ok(vectors.length >= 10, 'the corpus should carry at least ten actions, found ' + vectors.length); + }); + + it('obfuscate -> de-obfuscate is byte-exact for every canonical action', function () { + const failures = []; + for (const action of vectors) { + const txid = 'ab'.repeat(32); // the obfuscation key (4.2) is the first input txid + try { + const obfuscated = builder.obfuscateAction(action, txid); + const back = builder.deobfuscateAction(obfuscated, txid); + if (back !== action) failures.push(action.split('|')[0] + ': roundtrip changed the string'); + } catch (error) { + failures.push(action.split('|')[0] + ': threw ' + String(error.message).slice(0, 60)); + } + } + assert.deepStrictEqual(failures, [], 'every canonical action must survive the carrier intact'); + }); + + it('reports which canonical actions our own validator accepts, and fits the ceiling', function () { + const rejected = []; + const tooLong = []; + for (const action of vectors) { + try { builder.assertActionString(action); } + catch (error) { rejected.push(action.split('|')[0] + ' (' + String(error.message).slice(0, 40) + ')'); } + if (Buffer.byteLength(action, 'utf8') + 4 > builder.MAX_ACTION_DATA_LENGTH) tooLong.push(action.split('|')[0]); + } + // Not a defect list to fix here: it is the measurement of where our validator and the + // platform's corpus disagree. Printed so a divergence is visible in the test output itself. + console.log(' corpus vectors: ' + vectors.length + + ' · our validator rejects: ' + (rejected.length ? rejected.join(', ') : 'none') + + ' · over the ceiling: ' + (tooLong.length ? tooLong.join(', ') : 'none')); + assert.deepStrictEqual(tooLong, [], 'no canonical action may exceed the carrier ceiling'); + }); + + it('a malformed SWAP (the one that fooled us) is visibly different from the canonical one', function () { + const canonical = vectors.find((v) => v.startsWith('SWAP|')); + assert.ok(canonical, 'the corpus must carry a SWAP vector'); + const fields = canonical.split('|').length; + const mine = 'SWAP|0|BTC|GIVE|10|0|BTC|GET|5|0'; + assert.notStrictEqual(mine.split('|').length, fields, + 'the hand-made string must NOT look like the canonical one: that difference is the whole lesson'); + assert.ok(fields >= 13, 'SWAP v0 carries the positional fields the docs list, got ' + fields); + }); +}); diff --git a/test/unit/chain/kaspa/kaspa_block_decoder.test.js b/test/unit/chain/kaspa/kaspa_block_decoder.test.js new file mode 100644 index 00000000..3e278a97 --- /dev/null +++ b/test/unit/chain/kaspa/kaspa_block_decoder.test.js @@ -0,0 +1,279 @@ +/********************************************************************* + * + * 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. + * + ********************************************************************/ + +/********************************************************************* + * KaspaBlockDecoder — interface parity with the bitcoinjs adapter. + * + * The point of this file is the last suite: rather than trusting a + * hand-written list of fields, it re-derives the field set from the + * consumer sources (XChainDecoder/*.js) and asserts every access + * resolves on the Kaspa objects. If upstream starts reading a new + * field on a block or transaction, this goes red instead of silently + * handing back undefined. + **********************************************************************/ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const { KaspaBlockDecoder, toBigIntAmount } = require('../../../../src/chain/kaspa/kaspa_block_decoder.js'); + +const DECODER_SRC = path.join(__dirname, '..', '..', '..', '..', 'src'); + +// A minimal wRPC-shaped block: exactly the fields kaspad returns for +// getBlock / getVirtualChainFromBlockV2 (measured, see KASPA-ADAPTER-V2.md §11). +const RAW_TX = { + version: 0, + payload: Buffer.from('ORDER|0|DOGE||1000000', 'utf8').toString('hex'), + inputs: [{ + previousOutpoint: { transactionId: 'aa'.repeat(32), index: 0 }, + verboseData: { scriptPublicKeyAddress: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7' }, + }], + outputs: [ + { amount: '5000000000', scriptPublicKey: { version: 0, scriptPublicKey: '2065ab' }, + verboseData: { scriptPublicKeyAddress: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7' } }, + { amount: '100', scriptPublicKey: { version: 0, scriptPublicKey: '76a914' }, + verboseData: { scriptPublicKeyAddress: null } }, + ], + verboseData: { transactionId: 'bb'.repeat(32) }, +}; + +const RAW_BLOCK = { + header: { hash: 'cc'.repeat(32), selectedParentHash: 'dd'.repeat(32), timestamp: 1760000000000 }, + transactions: [RAW_TX], + verboseData: { hash: 'cc'.repeat(32) }, +}; + +describe('KaspaBlockDecoder: family surface', function () { + + it('declares the two fields XChainDecoder reads off a block decoder', function () { + const d = new KaspaBlockDecoder('kaspa-testnet'); + assert.strictEqual(d.coin, 'kaspa'); // startup.js reads .coin + assert.strictEqual(d.wireFormat, 'kaspa'); // XChainDecoder.js:208 reads .wireFormat + }); + + it('shapes a block into the fields the ingest path consumes', function () { + const block = new KaspaBlockDecoder('kaspa-testnet').blockFromObject(RAW_BLOCK); + assert.strictEqual(typeof block.timestamp, 'number'); // 13 call sites + assert.strictEqual(block.timestamp, 1760000000000); + assert.strictEqual(block.prevHash, 'dd'.repeat(32)); + assert.strictEqual(block.transactions.length, 1); + }); + + it('shapes a transaction: ins / outs / getId()', function () { + const tx = new KaspaBlockDecoder('kaspa-testnet').transactionFromObject(RAW_TX); + assert.strictEqual(tx.getId(), 'bb'.repeat(32)); + assert.strictEqual(typeof tx.getId, 'function'); + assert.strictEqual(tx.ins.length, 1); + assert.strictEqual(tx.outs.length, 2); + assert.strictEqual(tx.ins[0].hash, 'aa'.repeat(32)); + assert.strictEqual(tx.ins[0].index, 0); + assert.ok(Buffer.isBuffer(tx.outs[0].script), 'output.script must be a Buffer'); + }); + + it('keeps amounts as BigInt (VARCHAR(250) column, no Number() rounding)', function () { + const tx = new KaspaBlockDecoder('kaspa-testnet').transactionFromObject(RAW_TX); + assert.strictEqual(tx.outs[0].value, 5000000000n); + assert.strictEqual(toBigIntAmount('5000000000'), 5000000000n); + assert.throws(() => toBigIntAmount('1.5'), /not a non-negative integer/); + assert.throws(() => toBigIntAmount(''), /not a non-negative integer/); + }); + + it('folds the script version into byte 0 so versions cannot collide', function () { + const tx = new KaspaBlockDecoder('kaspa-testnet').transactionFromObject(RAW_TX); + assert.strictEqual(tx.outs[0].script[0], 0); + assert.strictEqual(tx.outs[0].script.slice(1).toString('hex'), '2065ab'); + }); + + it('carries the payload as the action string, and blanks an oversize one', function () { + const d = new KaspaBlockDecoder('kaspa-testnet'); + assert.strictEqual(d.transactionFromObject(RAW_TX).payload, 'ORDER|0|DOGE||1000000'); + assert.strictEqual(d.transactionFromObject(RAW_TX).payloadOversize, false); + + const huge = Object.assign({}, RAW_TX, { payload: Buffer.alloc(8193, 0x41).toString('hex') }); + const t = d.transactionFromObject(huge); + assert.strictEqual(t.payload, '', 'oversize action must be blanked, row still written'); + assert.strictEqual(t.payloadOversize, true); + }); + + it('fails loudly on the hex/buffer entry points instead of guessing an encoding', function () { + const d = new KaspaBlockDecoder('kaspa-testnet'); + assert.throws(() => d.blockFromHex('00'), /no raw-hex block RPC/); + assert.throws(() => d.transactionFromHex('00'), /no raw-hex transaction RPC/); + assert.throws(() => d.blockFromBuffer(Buffer.alloc(4)), /borsh framing/); + }); + + it('rejects a wire object with no transaction id rather than inventing one', function () { + const d = new KaspaBlockDecoder('kaspa-testnet'); + assert.throws(() => d.transactionFromObject({ inputs: [], outputs: [] }), /verboseData\.transactionId/); + assert.throws(() => d.blockFromObject({}), /needs a block object with a header/); + }); +}); + +describe('KaspaBlockDecoder: field set re-derived from the consumers', function () { + + // Every `block.` / `transaction.` / `output.` access inside the + // ingest path. Re-derived at test time so it cannot drift. + function fieldAccesses(receiver) { + const dir = path.join(DECODER_SRC, 'XChainDecoder'); + const names = new Set(); + for (const f of fs.readdirSync(dir)) { + if (!f.endsWith('.js')) continue; + const text = fs.readFileSync(path.join(dir, f), 'utf8'); + const re = new RegExp('\\b' + receiver + '\\.([a-zA-Z_][a-zA-Z0-9_]*)', 'g'); + let m; + while ((m = re.exec(text)) !== null) names.add(m[1]); + } + return names; + } + + it('every block. the consumers read exists on the Kaspa block', function () { + const block = new KaspaBlockDecoder('kaspa-testnet').blockFromObject(RAW_BLOCK); + // Receivers the consumers bind to a decoded block. + const wanted = fieldAccesses('block'); + const ignore = new Set(['index', 'hash', 'height', 'block_index']); // bound to other things in those files + for (const name of wanted) { + if (ignore.has(name)) continue; + assert.ok(name in block || typeof block[name] === 'function', + 'consumer reads block.' + name + ' but the Kaspa block does not provide it'); + } + }); + + it('every transaction. the consumers read exists on the Kaspa transaction', function () { + const tx = new KaspaBlockDecoder('kaspa-testnet').transactionFromObject(RAW_TX); + for (const receiver of ['transaction', 'nextTransaction', 'prevTransaction']) { + for (const name of fieldAccesses(receiver)) { + assert.ok(name in tx || typeof tx[name] === 'function', + 'consumer reads ' + receiver + '.' + name + ' but the Kaspa transaction does not provide it'); + } + } + }); + + it('the ingest path reads exactly the surface this decoder implements (no extra)', function () { + const tx = new KaspaBlockDecoder('kaspa-testnet').transactionFromObject(RAW_TX); + assert.deepStrictEqual(tx.ins[0].hash.length, 64); + assert.ok(Array.isArray(tx.outs)); + assert.strictEqual(typeof tx.getId(), 'string'); + assert.ok(tx.getId().length === 64, 'blake3 txid is 32 bytes of hex'); + }); +}); + +describe('KaspaBlockDecoder: malformed carrier', function () { + it('blanks an invalid, odd-length or non-hex payload instead of truncating it', function () { + const d = new KaspaBlockDecoder('kaspa-testnet'); + const base = { version: 0, inputs: [], outputs: [{ amount: '1', scriptPublicKey: { version: 0, scriptPublicKey: '65ab' } }], verboseData: { transactionId: 'ee'.repeat(32) } }; + const full = Buffer.from('ORDER|0|DOGE||1000000', 'utf8').toString('hex'); + const odd = full.slice(0, full.length - 1); + const t = d.transactionFromObject(Object.assign({}, base, { payload: odd })); + assert.strictEqual(t.payload, '', 'odd-length hex must blank the carrier'); + assert.strictEqual(t.payloadMalformed, true); + const ok = d.transactionFromObject(Object.assign({}, base, { payload: full })); + assert.strictEqual(ok.payload, 'ORDER|0|DOGE||1000000'); + assert.strictEqual(d.transactionFromObject(Object.assign({}, base, { payload: 'zz' })).payload, ''); + }); +}); + +describe('KaspaBlockDecoder: 4.2 recognition', function () { + // An INDEPENDENT implementation of the spec (not a call into our own helper), so the + // pin proves the two sides agree on the wire, not that a function equals itself. + function obfuscatePerSpec(action, txidHex) { + const crypto = require('crypto'); + const be = Buffer.from(txidHex, 'hex').reverse(); + const c = crypto.createCipheriv('aes-128-ctr', be.slice(0, 16), be.slice(16, 32)); + return Buffer.concat([c.update(Buffer.concat([Buffer.from('XCHN'), Buffer.from(action, 'utf8')])), c.final()]); + } + const TXID = 'aa'.repeat(32); + const WITH_INPUT = (payloadHex) => ({ version: 0, payload: payloadHex, + inputs: [{ previousOutpoint: { transactionId: TXID, index: 0 } }], + outputs: [{ amount: '1', scriptPublicKey: { version: 0, scriptPublicKey: '65ab' } }], + verboseData: { transactionId: 'ee'.repeat(32) } }); + + it('decodes a payload written by an independent implementation of the spec', function () { + const d = new KaspaBlockDecoder('kaspa-testnet'); + const tx = d.transactionFromObject(WITH_INPUT(obfuscatePerSpec('ORDER|0|DOGE||1000000', TXID).toString('hex'))); + assert.strictEqual(tx.payload, 'ORDER|0|DOGE||1000000'); + assert.strictEqual(tx.payloadUnobfuscated, false, 'recognised as an obfuscated carrier'); + }); + it('marks a cleartext payload instead of dropping it (legacy rows), and never truncates', function () { + const d = new KaspaBlockDecoder('kaspa-testnet'); + const tx = d.transactionFromObject(WITH_INPUT(Buffer.from('ORDER|0|DOGE||1000000').toString('hex'))); + assert.strictEqual(tx.payload, 'ORDER|0|DOGE||1000000'); + assert.strictEqual(tx.payloadUnobfuscated, true, 'countable so an operator can see when strict mode is safe'); + }); + it('pins the confirmation default to the 2-hour window (platform reorg tolerance)', function () { + const { DEFAULT_MIN_CONFIRMATION_COUNT } = require('../../../../src/chain/kaspa/kaspa_node_client.js'); + assert.strictEqual(DEFAULT_MIN_CONFIRMATION_COUNT, 72000); + }); +}); + +describe('KaspaBlockDecoder: 4.1 alias canonicalisation', function () { + const TXID = 'aa'.repeat(32); + function ob(action) { // independent spec implementation + const crypto = require('crypto'); + const be = Buffer.from(TXID, 'hex').reverse(); + const c = crypto.createCipheriv('aes-128-ctr', be.slice(0, 16), be.slice(16, 32)); + return Buffer.concat([c.update(Buffer.concat([Buffer.from('XCHN'), Buffer.from(action)])), c.final()]).toString('hex'); + } + const mk = (payloadHex) => ({ version: 0, payload: payloadHex, + inputs: [{ previousOutpoint: { transactionId: TXID, index: 0 } }], + outputs: [{ amount: '1', scriptPublicKey: { version: 0, scriptPublicKey: '65ab' } }], + verboseData: { transactionId: 'ee'.repeat(32) } }); + it('stores the canonical name the indexer dispatches on', function () { + const d = new KaspaBlockDecoder('kaspa-testnet'); + // 4.1: the decoder expands legacy aliases. Without this, a TRANSFER on Kaspa would + // be stored as TRANSFER and never dispatched -- a silently dropped action. + const aliased = d.transactionFromObject(mk(ob('TRANSFER|0|X'))); + assert.strictEqual(aliased.payload, 'SEND|0|X'); + assert.strictEqual(aliased.rawActionName, 'TRANSFER'); + assert.strictEqual(aliased.actionAliasExpanded, true); + const known = d.transactionFromObject(mk(ob('SEND|0|X'))); + assert.strictEqual(known.payload, 'SEND|0|X'); + assert.strictEqual(known.actionAliasExpanded, false); + const unknown = d.transactionFromObject(mk(ob('NOTANACTION|0|X'))); + assert.strictEqual(unknown.payload, 'NOTANACTION|0|X', 'unknown names stay untouched, as on the other chains'); + }); +}); + +describe('KaspaBlockDecoder: real wire fixture', function () { + // The P0 of the 2026-10-11 audit had a structural root cause: every fixture was HAND-WRITTEN + // cleartext, so the suite never met the shape a node actually delivers. This fixture was + // captured from a live kaspad v2.1.0 simnet node (8 spendable-input payload transactions in + // one chain block), and it is the sample that would have caught it. + const fixture = require('../../../fixtures/kaspa/chain_block_with_payload.json'); + + it('decodes a captured chain block without inventing anything', function () { + const d = new KaspaBlockDecoder('kaspa-simnet'); + const block = d.blockFromObject({ + header: { hash: fixture.chainBlock.hash, selectedParentHash: fixture.chainBlock.previousBlockHash, timestamp: fixture.chainBlock.timestamp }, + transactions: fixture.acceptedTransactions, + verboseData: { hash: fixture.chainBlock.hash }, + }); + assert.strictEqual(block.transactions.length, fixture.acceptedTransactions.length); + for (const tx of block.transactions) { + assert.ok(tx.getId().length === 64, 'every captured tx has a 32-byte txid'); + assert.ok(tx.outs.length >= 1, 'and at least one output'); + } + }); + + it('recovers the ACTION string that is actually on chain, and marks it as legacy cleartext', function () { + const d = new KaspaBlockDecoder('kaspa-simnet'); + const tx = d.transactionFromObject(fixture.acceptedTransactions[0]); + // Captured before the 4.2 obfuscation existed, so the carrier is cleartext: the decoder + // must still recover it (marked) rather than treating it as noise. + assert.strictEqual(tx.payloadUnobfuscated, true); + assert.ok(/^ORDER\|0\|DOGE\|\|1000000000\|/.test(tx.payload), 'the captured action survives decoding: ' + tx.payload.slice(0, 40)); + assert.strictEqual(tx.payload, Buffer.from(fixture.acceptedTransactions[0].payload, 'hex').toString('utf8')); + }); +}); diff --git a/test/unit/chain/kaspa/kaspa_ingest.test.js b/test/unit/chain/kaspa/kaspa_ingest.test.js new file mode 100644 index 00000000..b34d4a88 --- /dev/null +++ b/test/unit/chain/kaspa/kaspa_ingest.test.js @@ -0,0 +1,142 @@ +/********************************************************************* + * + * 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: the projection, asserted where Stryker can see it. + * + * A Stryker run over ingest() scored 15.69% (8 killed / 43 survived) and every + * survivor sat on the dispenser conditions — `if (dispenser && source)` could + * become `if (true)` or `||` and nothing noticed, because the only dispenser + * assertions lived outside this repository. The cases below are the ones that + * distinguish those branches. + **********************************************************************/ +'use strict'; +const assert = require('assert'); + +const { ingest, rollback, IngestState, chainTimeToSeconds } = require('../../../../src/chain/kaspa/kaspa_ingest.js'); + +const SRC = 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7'; +const DST = 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7'; +const B = (hash, prev, ts, txs) => ({ hash, previousBlockHash: prev, timestamp: ts, acceptedTransactions: txs }); +const T = (id, carrier, { source = SRC, withInput = true } = {}) => ({ + verboseData: { transactionId: id }, + payload: carrier == null ? '' : Buffer.from(carrier, 'utf8').toString('hex'), + inputs: withInput ? [{ previousOutpoint: { transactionId: 'aa'.repeat(32), index: 0 }, verboseData: { scriptPublicKeyAddress: source } }] : [], + outputs: [{ amount: '5', verboseData: { scriptPublicKeyAddress: DST } }], +}); + +describe('kaspa_ingest: dispenser bookkeeping (the surviving-mutant branches)', function () { + + it('registers an open dispenser only when there is BOTH a dispenser action and a source', function () { + const withSource = ingest([B('b1'.repeat(32), 'b0'.repeat(32), 1000, [T('t1'.repeat(32), 'DISPENSER|0|DOGE||1000')])]).rows; + assert.strictEqual(withSource.dispensers.length, 1, 'a funded dispenser open is registered'); + assert.strictEqual(withSource.dispensers[0].expiration, null, 'expiration stays NULL (never guessed)'); + assert.strictEqual(withSource.dispensers[0].expired_block_index, null, 'and it is open'); + + // Same carrier, but no input at all: `dispenser && source` must be false here, which is + // exactly what the `if (true)` and `||` mutants get wrong. + const noSource = ingest([B('b1'.repeat(32), 'b0'.repeat(32), 1000, [T('t1'.repeat(32), 'DISPENSER|0|DOGE||1000', { withInput: false })])]).rows; + assert.strictEqual(noSource.dispensers.length, 0, 'a dispenser with no source must not be registered'); + }); + + it('stamps the closing block on DISPENSER_CLOSE and leaves other actions alone', function () { + const state = new IngestState(); + ingest([B('b1'.repeat(32), 'b0'.repeat(32), 1000, [T('t1'.repeat(32), 'DISPENSER|0|DOGE||1000')])], { state }); + const closed = ingest([B('b2'.repeat(32), 'b1'.repeat(32), 1001, [T('t2'.repeat(32), 'DISPENSER_CLOSE|0')])], { state }).rows; + assert.strictEqual(closed.dispensers.length, 1); + assert.strictEqual(closed.dispensers[0].expired_block_index, 2, 'closed at the block that carried the close'); + assert.strictEqual(state.openDispensers.size, 0, 'and the open map is emptied'); + + const alsoState = new IngestState(); + const unrelated = ingest([B('b1'.repeat(32), 'b0'.repeat(32), 1000, + [T('t1'.repeat(32), 'DISPENSER|0|DOGE||1000'), T('t2'.repeat(32), 'ORDER|0|DOGE||1')])], { state: alsoState }).rows; + assert.strictEqual(unrelated.dispensers.length, 1, 'ORDER must not touch the dispenser table'); + assert.strictEqual(alsoState.openDispensers.size, 1, 'and must not close anything'); + }); +}); + +describe('kaspa_ingest: the projection invariants', function () { + + it('writes destination_id as the empty placeholder and keeps real addresses in outputs', function () { + const rows = ingest([B('b1'.repeat(32), 'b0'.repeat(32), 1000, [T('t1'.repeat(32), 'ORDER|0|DOGE||1')])]).rows; + assert.strictEqual(rows.transactions[0].destination_id, 1); + assert.strictEqual(rows.transaction_outputs.length, 1); + const dest = rows.index_addresses.find((a) => a.id === rows.transaction_outputs[0].destination_id); + assert.strictEqual(dest.address, DST); + }); + + it('is deterministic: the same slice twice is byte-identical, and split == whole', function () { + const blocks = [ + B('b1'.repeat(32), 'b0'.repeat(32), 1000, [T('t1'.repeat(32), 'ORDER|0|DOGE||1')]), + B('b2'.repeat(32), 'b1'.repeat(32), 1001, [T('t2'.repeat(32), 'SEND|0|DOGE||2')]), + ]; + assert.deepStrictEqual(ingest(blocks).rows, ingest(blocks).rows, 'same input, same rows'); + const state = new IngestState(); + ingest([blocks[0]], { state }); + const split = ingest([blocks[1]], { state }).rows; + assert.deepStrictEqual(split, ingest(blocks).rows, 'two appends == one append'); + }); + + it('rolls back for real and rewinds the cursors', function () { + const state = new IngestState(); + ingest([B('b1'.repeat(32), 'b0'.repeat(32), 1000, [T('t1'.repeat(32), 'ORDER|0|DOGE||1')])], { state }); + ingest([B('b2'.repeat(32), 'b1'.repeat(32), 1001, [T('t2'.repeat(32), 'SEND|0|DOGE||2')])], { state }); + rollback(state, ['b2'.repeat(32)]); + const rows = state.rows(); + assert.strictEqual(rows.blocks.length, 1); + assert.strictEqual(rows.transactions.length, 1); + assert.strictEqual(rows.transaction_outputs.length, 1, 'child rows go with their parent'); + assert.strictEqual(state.nextBlockIndex, 2, 'the block cursor is rewound'); + assert.strictEqual(state.nextTxIndex, 2, 'and so is the tx cursor'); + }); + + it('normalises chain time to the platform unit (seconds)', function () { + assert.strictEqual(chainTimeToSeconds(1791716119613), 1791716119); + assert.strictEqual(chainTimeToSeconds(1791716119), 1791716119); + assert.strictEqual(chainTimeToSeconds(null), null); + }); +}); + +describe('kaspa_ingest: the platform storage gate (found on real data)', function () { + // Surfaced only by the real-database run: a coinbase payload is BINARY, and it was landing + // in `data`, where the indexer would have logged PARSE_ERROR on every block. The platform + // rule is the storage gate: an unrecognised action is blanked, the row is still written, + // and the bytes survive in raw_data. + const coinbaseBytes = '0000000000000000' + Buffer.from('kaspa-simnet', 'utf8').toString('hex'); + const cbTx = { + verboseData: { transactionId: 'cb'.repeat(32) }, + payload: coinbaseBytes, + inputs: [{ previousOutpoint: null, verboseData: {} }], + outputs: [{ amount: '5', verboseData: { scriptPublicKeyAddress: DST } }], + }; + + it('blanks a binary coinbase payload, writes the row anyway, keeps raw_data', function () { + const rows = ingest([B('b1'.repeat(32), 'b0'.repeat(32), 1000, [cbTx])]).rows; + assert.strictEqual(rows.transactions.length, 1, 'the row is still written'); + assert.strictEqual(rows.transactions[0].data, null, 'binary never reaches data'); + assert.ok(rows.transactions[0].raw_data && rows.transactions[0].raw_data.length > 0, 'but raw_data keeps the bytes'); + }); + + it('blanks an unknown-but-ASCII name and keeps a known ACTION', function () { + const rows = ingest([B('b1'.repeat(32), 'b0'.repeat(32), 1000, [ + T('t1'.repeat(32), 'MADEUP|0|X'), T('t2'.repeat(32), 'ORDER|0|DOGE||1'), + ])]).rows; + assert.strictEqual(rows.transactions[0].data, null, 'unknown name is blanked, as on the other chains'); + assert.strictEqual(rows.transactions[1].data, 'ORDER|0|DOGE||1', 'a known ACTION is stored'); + }); + + it('still expands an alias before the gate (4.1 + gate together)', function () { + const rows = ingest([B('b1'.repeat(32), 'b0'.repeat(32), 1000, [T('t1'.repeat(32), 'TRANSFER|0|X')])]).rows; + assert.strictEqual(rows.transactions[0].data, 'SEND|0|X', 'the alias expands to a KNOWN name, so the gate keeps it'); + }); +}); diff --git a/test/unit/chain/kaspa/kaspa_node_client.test.js b/test/unit/chain/kaspa/kaspa_node_client.test.js new file mode 100644 index 00000000..77a0a85e --- /dev/null +++ b/test/unit/chain/kaspa/kaspa_node_client.test.js @@ -0,0 +1,184 @@ +/********************************************************************* + * + * 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_node_client: the wire-facing half of the family. + * + * This file used to hold a single case, so a Stryker run over the client had almost + * nothing to kill. The cases below are the failure modes a wRPC socket actually + * produces: a reply for an id nobody waits for, a frame that is not JSON, an error + * object, a close in mid-flight, and a timeout that must not hang its caller. + * + * NOTE ON THE FAKES: WebSocketImpl is invoked with `new`, so every fake here is a + * class. An arrow function cannot be constructed and fails with a confusing + * "is not a constructor" instead of testing anything. + **********************************************************************/ +'use strict'; +const assert = require('assert'); + +const { createRpcClient } = + require('../../../../src/chain/kaspa/kaspa_node_client.js'); + +const H = (c) => c.repeat(32); + +// A scriptable socket. It records what was sent and lets a test drive open/message/close. +class FakeSocket { + constructor({ autoOpen = true, onSend = null } = {}) { + this.readyState = 0; + this.handlers = {}; + this.sent = []; + this.onSend = onSend; + this.closed = false; + if (autoOpen) setTimeout(() => this.open(), 0); + } + on(name, fn) { this.handlers[name] = fn; return this; } + open() { this.readyState = 1; if (this.handlers.open) this.handlers.open(); } + send(text) { this.sent.push(text); if (this.onSend) this.onSend(JSON.parse(text), this); } + reply(id, result) { if (this.handlers.message) this.handlers.message(JSON.stringify({ id, result })); } + replyError(id, message) { if (this.handlers.message) this.handlers.message(JSON.stringify({ id, error: { message } })); } + emitRaw(text) { if (this.handlers.message) this.handlers.message(text); } + // Real socket events are asynchronous: firing 'close' synchronously would null the client's + // socket reference before the pending call even reached send(), which is not what a socket does. + close() { this.closed = true; this.readyState = 3; if (this.handlers.close) setTimeout(() => this.handlers.close(), 0); } +} + +// Transport factories. Each one receives the url, exactly like a real WebSocket would. +const makeTransport = (sockets, build) => class { + constructor(url) { + const socket = build(url); + sockets.push(socket); + return socket; // hand back the scriptable socket itself + } +}; +const answering = (sockets) => makeTransport(sockets, () => + new FakeSocket({ onSend: (req, socket) => socket.reply(req.id, { ok: req.method }) })); +const silent = (sockets) => makeTransport(sockets, () => new FakeSocket()); + +const client = (sockets, options = {}) => createRpcClient(Object.assign({ + url: 'ws://fake/json', timeoutMs: 500, WebSocketImpl: answering(sockets), +}, options)); + +describe('createRpcClient: construction', function () { + + it('refuses to be built without a url', function () { + assert.throws(() => createRpcClient({}), /needs a url/); + }); + + it('uses the WebSocket implementation it is handed', async function () { + const sockets = []; + const c = client(sockets); + await c.connect(); + assert.strictEqual(sockets.length, 1, 'the implementation was constructed exactly once'); + await c.disconnect(); + }); +}); + +describe('createRpcClient: handshake and calls', function () { + + it('resolves a call with the reply that carries its own id', async function () { + const sockets = []; + const c = client(sockets); + await c.connect(); + const result = await c.call('getServerInfo', { x: 1 }); + assert.deepStrictEqual(result, { ok: 'getServerInfo' }); + const sent = JSON.parse(sockets[0].sent[0]); + assert.strictEqual(sent.method, 'getServerInfo'); + assert.deepStrictEqual(sent.params, { x: 1 }); + await c.disconnect(); + }); + + it('ignores a frame that is not JSON, and a reply for an id nobody waits for', async function () { + const sockets = []; + const c = client(sockets, { + WebSocketImpl: makeTransport(sockets, () => new FakeSocket({ + onSend: (req, socket) => { + socket.emitRaw('not json at all'); + socket.emitRaw('{"id":999999,"result":"nobody"}'); + socket.reply(req.id, 'ok'); + }, + })), + }); + await c.connect(); + assert.strictEqual(await c.call('getBlockDagInfo'), 'ok', 'garbage frames must not break the client'); + await c.disconnect(); + }); + + it('times out with the method name in the message', async function () { + const sockets = []; + const c = client(sockets, { timeoutMs: 60, WebSocketImpl: silent(sockets) }); + await c.connect(); + await assert.rejects(() => c.call('getServerInfo'), /getServerInfo timed out after 60ms/); + await c.disconnect(); + }); + + it('rejects with the message when the reply is an error object', async function () { + const sockets = []; + const c = client(sockets, { + WebSocketImpl: makeTransport(sockets, () => new FakeSocket({ onSend: (req, socket) => socket.replyError(req.id, 'no such method') })), + }); + await c.connect(); + await assert.rejects(() => c.call('nope'), /no such method/, 'a JSON-RPC error must surface as a rejection'); + await c.disconnect(); + }); +}); + +describe('createRpcClient: disconnect', function () { + + it('rejects in-flight calls instead of hanging until their timeout', async function () { + // Note for the product side: a disconnect that lands between `call()`'s await and its + // send() makes socket null, so the caller sees a TypeError out of socket.send instead of + // the documented 'wRPC client disconnected'. That race is pinned as a tolerated gap here; + // this case gives the call its turn to register first, which is the ordinary ordering. + const sockets = []; + const c = client(sockets, { timeoutMs: 60000, WebSocketImpl: silent(sockets) }); + await c.connect(); + const inFlight = c.call('getServerInfo'); + inFlight.catch(() => {}); + await new Promise((r) => setTimeout(r, 5)); // let the call register in `pending` + await c.disconnect(); + await assert.rejects(() => inFlight, /disconnected/); + }); + + it('is terminal: a call after disconnect fails fast', async function () { + const sockets = []; + const c = client(sockets); + await c.connect(); + await c.disconnect(); + await assert.rejects(() => c.call('getServerInfo'), /disconnected/); + }); + + it('rejects pending calls when the socket closes under them', async function () { + const sockets = []; + const c = client(sockets, { timeoutMs: 5000, WebSocketImpl: silent(sockets) }); + await c.connect(); + const inFlight = c.call('getServerInfo'); + inFlight.catch(() => {}); + sockets[0].close(); + await assert.rejects(() => inFlight, /socket closed|disconnected/); + }); +}); + +describe('createRpcClient: the disconnect race (P3 pin)', function () { + it('a disconnect in the middle of call() yields the documented error, not a TypeError', async function () { + // The race: call() awaits ensureOpen(), disconnect() runs inside that window and nulls the + // socket, and the caller used to get "Cannot read properties of null (reading 'send')". + const sockets = []; + const c = client(sockets, { timeoutMs: 60000, WebSocketImpl: silent(sockets) }); + await c.connect(); + const inFlight = c.call('getServerInfo'); + inFlight.catch(() => {}); + await c.disconnect(); // no tick in between: this is the race + await assert.rejects(() => inFlight, /wRPC client disconnected/, + 'callers branch on this message, so a TypeError is a contract break'); + }); +}); diff --git a/test/unit/chain/kaspa/kaspa_service.test.js b/test/unit/chain/kaspa/kaspa_service.test.js new file mode 100644 index 00000000..65737973 --- /dev/null +++ b/test/unit/chain/kaspa/kaspa_service.test.js @@ -0,0 +1,306 @@ +/********************************************************************* + * + * 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_service: the part that must not be wrong. + * + * This was the only production file in the family with no test in the tree, and + * the largest: transactions, backoff, the checkpoint, the mempool gate. A + * Stryker run over the rest of the family started at 8.22% and 15.69% before its + * tests moved in-repo, so the reasoning below is the same one: the assertions + * have to be where the mutation runner can execute them. + **********************************************************************/ +'use strict'; +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { KaspaDecoderService, classifyError } = require('../../../../src/chain/kaspa/kaspa_service.js'); + +const quiet = { error() {}, warn() {}, info() {}, debug() {}, child() { return this; } }; +const H = (c) => c.repeat(32); // 2 chars x 32 = a 64-hex block hash, the real shape + +// A recording connection: the service only ever talks to the DB through +// query/beginTransaction/commit/rollback, so that is all this needs to fake. +function fakeConn({ failOnInsert = 0, failWith = null } = {}) { + const log = { queries: [], begins: 0, commits: 0, rollbacks: 0, inserts: 0, failOnInsert, failWith }; + return Object.assign(log, { + async query(sql) { + log.queries.push(String(sql)); + // Fail on the Nth WRITE, not the Nth query: an absolute index breaks whenever a + // read is added anywhere earlier in the tick (it did, twice). + if (/^(INSERT|DELETE)/i.test(String(sql))) { + log.inserts++; + if (failOnInsert > 0 && log.inserts === failOnInsert) throw new Error(failWith || 'E'); + } + if (/SELECT MAX\(block_index\)/i.test(String(sql))) return [{ maxIndex: null }]; + if (/FROM `kaspa_decoder_state`/i.test(String(sql))) return []; + return []; + }, + async beginTransaction() { log.begins++; }, + async commit() { log.commits++; }, + async rollback() { log.rollbacks++; }, + }); +} + +// A two-block slice with one real ACTION, in the shape the node delivers. +function fakeRpc() { + const tx = (id, carrier) => ({ + verboseData: { transactionId: id }, payload: Buffer.from(carrier, 'utf8').toString('hex'), + inputs: [{ previousOutpoint: { transactionId: 'aa'.repeat(32), index: 0 }, verboseData: { scriptPublicKeyAddress: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7' } }], + outputs: [{ amount: '5', verboseData: { scriptPublicKeyAddress: 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7' } }], + }); + const blocks = [ + { chainBlockHeader: { hash: H('b1'), selectedParentHash: H('b0'), timestamp: 1791716119000 }, acceptedTransactions: [tx(H('t1'), 'ORDER|0|DOGE||1')] }, + { chainBlockHeader: { hash: H('b2'), selectedParentHash: H('b1'), timestamp: 1791716120000 }, acceptedTransactions: [tx(H('t2'), 'SEND|0|DOGE||2')] }, + ]; + return { + async connect() {}, async disconnect() {}, + async getBlockDagInfo() { return { pruningPointHash: H('b0') }; }, + async getVirtualChainFromBlockV2() { return { chainBlockAcceptedTransactions: blocks, removedChainBlockHashes: [] }; }, + async getBlockCount() { return { blockCount: '2', headerCount: '2' }; }, + async getMempoolEntries() { return []; }, + }; +} + +const service = (over = {}) => new KaspaDecoderService(Object.assign({ + rpc: fakeRpc(), conn: fakeConn(), networkId: 'simnet', minConfirmationCount: 0, + checkpointFile: null, logger: quiet, +}, over)); + +describe('kaspa_service: error classification', function () { + + it('splits transport/database trouble from data trouble, deliberately', function () { + for (const message of ['Connection lost: The server closed the connection', 'PROTOCOL_CONNECTION_LOST', + 'connect ECONNREFUSED 127.0.0.1:18510', 'Lock wait timeout exceeded', 'Too many connections']) { + assert.strictEqual(classifyError(new Error(message)), 'recoverable', message); + } + for (const message of ['unknown variant `high`', 'ER_BAD_FIELD_ERROR: Unknown column', 'payload exceeds the carrier limit']) { + assert.strictEqual(classifyError(new Error(message)), 'fatal', message); + } + }); +}); + +describe('kaspa_service: transactions, tick and rollback', function () { + + it('wraps one tick in exactly one transaction and commits once', async function () { + const conn = fakeConn(); + const svc = service({ conn }); + await svc.init(); + const result = await svc.tick(); + assert.strictEqual(conn.begins, 1, 'one transaction per tick'); + assert.strictEqual(conn.commits, 1); + assert.strictEqual(conn.rollbacks, 0); + assert.strictEqual(result.blocks, 2, 'both blocks in the slice'); + assert.ok(result.writeStatements > 0, 'and statements were issued'); + }); + + it('rolls back on a recoverable failure and lets the run loop retry it', async function () { + // The contract: a recoverable DB failure is NOT swallowed here - it rolls the + // transaction back and propagates, and run() is what backs off and retries. Asserting + // a silent { skipped: true } here would be asserting a softer contract than the code has. + const recoverable = fakeConn({ failOnInsert: 1, failWith: 'Connection lost: The server closed the connection' }); + const svc = service({ conn: recoverable }); + await svc.init(); + await assert.rejects(() => svc.tick(), /Connection lost/); + assert.strictEqual(recoverable.rollbacks, 1, 'the transaction was rolled back'); + assert.strictEqual(recoverable.commits, 0, 'and nothing was committed'); + + const fatal = fakeConn({ failOnInsert: 1, failWith: 'ER_BAD_FIELD_ERROR: Unknown column' }); + const svc2 = service({ conn: fatal }); + await svc2.init(); + await assert.rejects(() => svc2.tick(), /ER_BAD_FIELD_ERROR/, 'a data error must not be swallowed'); + }); + + it('never writes outside a transaction', async function () { + const conn = fakeConn(); + const svc = service({ conn }); + await svc.init(); + const beforeInit = conn.queries.length; + await svc.tick(); + assert.ok(conn.queries.length > beforeInit, 'the tick issued statements'); + assert.strictEqual(conn.begins, 1, 'and every one of them was inside the transaction'); + }); +}); + +describe('kaspa_service: the checkpoint', function () { + + it('starts from the pruning point, and reconciles the WRITE FLOOR with the database', async function () { + const fresh = service({}); + assert.strictEqual(await fresh.init(), H('b0'), 'fresh install starts at the pruning point'); + + // The database is the authority for the floor (it decides what an incremental write + // skips); the cursor hash still comes from the checkpoint file or the pruning point. + const ahead = fakeConn(); + ahead.query = async (sql) => { + if (/MAX\(block_index\)/i.test(String(sql))) return [{ max_written: 7 }]; + return []; + }; + const resumed = service({ conn: ahead }); + assert.strictEqual(await resumed.init(), H('b0'), 'the cursor is not invented from the DB'); + assert.strictEqual(resumed.writtenBlocks, 7, 'but the write floor follows the database'); + }); + + it('writes atomically (temp + rename) and a new instance restores from it', async function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kaspa-cp-')); + const file = path.join(dir, 'checkpoint.json'); + const first = service({ checkpointFile: file }); + await first.init(); + await first.tick(); + const written = JSON.parse(fs.readFileSync(file, 'utf8')); + assert.strictEqual(written.checkpoint, H('b2'), 'the tip of the written slice'); + assert.strictEqual(written.networkId, 'simnet'); + assert.ok(written.writtenBlocks >= 2); + assert.ok(typeof written.savedAt === 'string', 'savedAt is metadata, not a decision input'); + + const second = service({ checkpointFile: file }); + assert.strictEqual(await second.init(), H('b2'), 'and it is restored on the next start'); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('the DATABASE wins over the file for the write floor, whatever order init() runs in', async function () { + // The blind spot a mutation run found in this very file: the case above has no checkpoint + // file, so it cannot tell whether the reconciliation runs before or after the file is + // read. A file claiming 99 while the database says 3 must end at 3 - the file's floor + // would silently skip every row between 3 and 99. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kaspa-cp3-')); + const file = path.join(dir, 'checkpoint.json'); + fs.writeFileSync(file, JSON.stringify({ checkpoint: H('b2'), networkId: 'simnet', writtenBlocks: 99, ticks: 5, savedAt: 'x' })); + const conn = fakeConn(); + conn.query = async (sql) => { + if (/MAX\(block_index\)/i.test(String(sql))) return [{ max_written: 3 }]; + return []; + }; + const svc = service({ conn, checkpointFile: file }); + await svc.init(); + assert.strictEqual(svc.writtenBlocks, 3, 'the database is the authority for the floor'); + assert.strictEqual(svc.checkpoint, H('b2'), 'and the cursor still comes from the file'); + fs.rmSync(dir, { recursive: true, force: true }); + }); + it('ignores a checkpoint written for another network', async function () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kaspa-cp2-')); + const file = path.join(dir, 'checkpoint.json'); + fs.writeFileSync(file, JSON.stringify({ checkpoint: H('b2'), networkId: 'mainnet', writtenBlocks: 9, ticks: 1, savedAt: 'x' })); + const svc = service({ checkpointFile: file, networkId: 'simnet' }); + assert.strictEqual(await svc.init(), H('b0'), 'mainnet progress must never leak into a simnet run'); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); + +describe('kaspa_service: the mempool gate and stop', function () { + + it('honours the interval on an injected clock, and lets the caller advance it', async function () { + const conn = fakeConn(); + const svc = service({ conn, mempoolIntervalMs: 1000 }); + await svc.init(); + const first = await svc.pollMempoolOnce([], { now: 10000 }); + assert.notStrictEqual(first.skipped, true, 'first call at t=10000 runs'); + const tooSoon = await svc.pollMempoolOnce([], { now: 10500 }); + assert.strictEqual(tooSoon.skipped, true); + assert.strictEqual(tooSoon.reason, 'interval-not-reached'); + const later = await svc.pollMempoolOnce([], { now: 11001 }); + assert.notStrictEqual(later.skipped, true, 'past the interval it runs again'); + }); + + it('stops idempotently and keeps its own counters', async function () { + const svc = service({}); + await svc.init(); + await svc.tick(); + svc.requestStop(); // the real name: requestStop(), not stop() + svc.requestStop(); + assert.strictEqual(svc.stopping, true, 'the run loop is asked to stop, idempotently'); + assert.strictEqual(svc.ticks, 1); + assert.strictEqual(svc.networkId, 'simnet'); + assert.strictEqual(svc.checkpoint, H('b2'), 'the cursor follows the blocks that were written'); + }); +}); + +describe('kaspa_service: an unresolvable reorg must refuse to write (P2)', function () { + it('refuses to write when the removal set cannot be resolved', async function () { + // Read from the bytes: when no removed chain block can be located in the hash map or in + // the database, `rollbackFrom` stays null, the write floor does not move, and the + // replacement blocks are then filtered out as already-written - a silent divergence that + // only leaves an error line in the log. fail-closed means: refuse the write. + const conn = fakeConn(); + conn.query = async (sql) => { + if (/JOIN `index_transactions`/i.test(String(sql))) return []; // the hash is nowhere + if (/MAX\(block_index\)/i.test(String(sql))) return [{ max_written: 5 }]; + return []; + }; + const rpc = fakeRpc(); + const answer = await rpc.getVirtualChainFromBlockV2({}); + rpc.getVirtualChainFromBlockV2 = async () => Object.assign({}, answer, { + removedChainBlockHashes: ['ff'.repeat(32)], // unknown to us both ways + }); + const svc = new KaspaDecoderService({ rpc, conn, networkId: 'simnet', minConfirmationCount: 0, + checkpointFile: null, logger: quiet }); + await svc.init(); + await assert.rejects(() => svc.tick(), /cannot resolve|refus/i, + 'an unresolvable reorg must not be written silently'); + assert.strictEqual(conn.commits, 0, 'and nothing may be committed'); + }); +}); + +describe('kaspa_service: the working set stays bounded (P1)', function () { + it('drops rows it has already written, and never lets the cursor go backwards', async function () { + // Read from the bytes: only state.events was ever cleared, so blocks/transactions and the + // two id maps grew for the whole life of the process, and every tick re-materialised them. + const h64 = (n) => String(n).padStart(2, '0').repeat(32); + let height = 0; + const rpc = fakeRpc(); + rpc.getVirtualChainFromBlockV2 = async () => { + height += 1; + const hash = h64(height); + return { + chainBlockAcceptedTransactions: [{ + chainBlockHeader: { hash, selectedParentHash: height > 1 ? h64(height - 1) : h64(0), timestamp: 1000 + height }, + acceptedTransactions: [], + }], + removedChainBlockHashes: [], addedChainBlockHashes: [hash], + }; + }; + const svc = new KaspaDecoderService({ rpc, conn: fakeConn(), networkId: 'simnet', + minConfirmationCount: 0, checkpointFile: null, logger: quiet }); + await svc.init(); + + const sizes = []; + for (let i = 0; i < 10; i += 1) { await svc.tick(); sizes.push(svc.state.blocks.length); } + assert.ok(svc.state.blocks.length <= 1, + 'only the un-written tail may remain, found ' + svc.state.blocks.length); + assert.ok(sizes.every((n) => n <= 1), 'and it must never grow across ticks: ' + sizes.join(',')); + assert.strictEqual(svc.state.transactions.length, 0, 'transactions above the floor likewise'); + assert.strictEqual(svc.checkpoint, h64(10), 'the cursor points at the last WRITTEN block'); + }); +}); + +describe('kaspa_service: run() initialises inside its own retry loop (P2 asymmetry)', function () { + it('starts the reader even when the node is not up at first', async function () { + // Measured on a closed port: init() outside the loop threw ECONNREFUSED, the part's start + // promise rejected, and the chain was never read - while a node that dies later is retried. + let attempts = 0; + const rpc = fakeRpc(); + const realDag = rpc.getBlockDagInfo.bind(rpc); + rpc.getBlockDagInfo = async () => { + attempts += 1; + if (attempts === 1) throw new Error('connect ECONNREFUSED 127.0.0.1:18999'); + return realDag(); + }; + const svc = new KaspaDecoderService({ rpc, conn: fakeConn(), networkId: 'simnet', + minConfirmationCount: 0, checkpointFile: null, logger: quiet, baseBackoffMs: 10 }); + assert.strictEqual(svc.checkpoint, null, 'not initialised to begin with'); + await svc.run({ intervalMs: 1, maxTicks: 2 }); + assert.notStrictEqual(svc.checkpoint, null, 'run() must have initialised despite the first failure'); + assert.ok(attempts >= 2, 'and it must have retried: ' + attempts); + }); +}); diff --git a/test/unit/chain/kaspa/kaspa_store.test.js b/test/unit/chain/kaspa/kaspa_store.test.js new file mode 100644 index 00000000..1b04bbdd --- /dev/null +++ b/test/unit/chain/kaspa/kaspa_store.test.js @@ -0,0 +1,380 @@ +/********************************************************************* + * + * 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_store: the SQL plan, asserted at the level Stryker can see. + * + * Why this file exists at all: the family had no store test in the tree, so a + * Stryker run over buildWritePlan scored 8.22% (6 killed / 67 survived) — every + * mutant that changed a table name or dropped an ON DUPLICATE KEY clause lived + * on, because the assertions only asked "is there a statement", never "which + * table, with which idempotence". These tests ask the second question. + **********************************************************************/ +'use strict'; +const assert = require('assert'); + +const { buildWritePlan, buildRollbackPlan, insertSql, buildMempoolWritePlan, SCHEMA } = + require('../../../../src/chain/kaspa/kaspa_store.js'); +const { ingest } = require('../../../../src/chain/kaspa/kaspa_ingest.js'); + +// Self-contained fixtures: this file borrows no helper from another test file. +const SOURCE = 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7'; +const DEST = 'kaspatest:qquxrkeluxz9kqjce85m5tm6887l86m9elvlgql06h686ucs78vtyjs4qdlw7'; +const mkTx = (id, carrier) => ({ + verboseData: { transactionId: id }, + payload: Buffer.from(carrier, 'utf8').toString('hex'), + inputs: [{ previousOutpoint: { transactionId: 'aa'.repeat(32), index: 0 }, verboseData: { scriptPublicKeyAddress: SOURCE } }], + outputs: [{ amount: '5', verboseData: { scriptPublicKeyAddress: DEST } }], +}); +const DISPENSER_BLOCK = { + hash: 'b1'.repeat(32), previousBlockHash: 'b0'.repeat(32), timestamp: 1791716119, + acceptedTransactions: [mkTx('d1'.repeat(32), 'DISPENSER|0|DOGE||1000')], +}; +const REORG_EVENT = { time: '2026-10-11 10:55:19', code: 'REORG', data: '[{"block_index":1}]' }; + +describe('kaspa_store: write plan', function () { + + const plan = buildWritePlan(ingest([DISPENSER_BLOCK]).rows, + { sinceBlockIndex: 0, events: [REORG_EVENT] }); + const ofTable = (name) => plan.filter((s) => s.sql.startsWith('INSERT INTO `' + name + '`')); + + it('names each table exactly, and produces the rows a dispenser block needs', function () { + for (const name of ['blocks', 'transactions', 'transaction_outputs', 'dispensers', 'events']) { + assert.ok(ofTable(name).length >= 1, 'must emit a statement for ' + name); + } + assert.strictEqual(ofTable('events').length, 1, 'exactly this tick\'s event'); + assert.ok(!plan.some((s) => s.sql.includes('INTO ``')), 'no empty table name'); + }); + + it('marks the lookup tables idempotent, so replaying a slice cannot fail', function () { + for (const name of ['dispensers', 'pubkeys', 'index_transactions', 'index_addresses']) { + for (const statement of ofTable(name)) { + assert.ok(statement.sql.includes('ON DUPLICATE KEY UPDATE'), name + ' must upsert'); + } + } + }); + + it('writes the events that are handed to it even when no block is new', function () { + const onlyEvents = buildWritePlan({ blocks: [{ block_index: 1 }], transactions: [] }, + { sinceBlockIndex: 1, events: [REORG_EVENT] }); + assert.strictEqual(onlyEvents.length, 1); + assert.ok(onlyEvents[0].sql.startsWith('INSERT INTO `events`')); + assert.strictEqual(buildWritePlan({ blocks: [{ block_index: 1 }], transactions: [] }, { sinceBlockIndex: 1 }).length, 0); + }); + + it('insertSql refuses an unknown table and fills missing columns with NULL', function () { + assert.throws(() => insertSql('nope', {}), /unknown table/); + const s = insertSql('blocks', { block_index: 3 }); + assert.strictEqual(s.values.length, SCHEMA.blocks.length); + assert.strictEqual(s.values[0], 3); + }); +}); + +describe('kaspa_store: rollback plan', function () { + + it('deletes children before parents and never touches the lookup or event tables', function () { + const rp = buildRollbackPlan(7); + assert.strictEqual(rp.length, 4); + assert.ok(rp[0].sql.includes('transaction_outputs')); + assert.ok(rp[1].sql.includes('dispensers')); + assert.ok(rp[2].sql.startsWith('DELETE FROM `transactions`')); + assert.ok(rp[3].sql.startsWith('DELETE FROM `blocks`')); + for (const statement of rp) { + assert.deepStrictEqual(statement.values, [7]); + assert.ok(!/index_transactions|index_addresses|pubkeys|`events`/.test(statement.sql)); + } + }); + + it('rejects a bad floor rather than deleting everything', function () { + assert.throws(() => buildRollbackPlan(-1), /non-negative integer/); + assert.throws(() => buildRollbackPlan('abc'), /non-negative integer/); + }); +}); + +describe('kaspa_store: mempool mirror', function () { + + it('keeps sompi amounts as strings (Number() rounds past 2^53-1)', function () { + const far = '9007199254740993'; + const p = buildMempoolWritePlan([{ tx_hash: 'aa', amount: far, fee: far }]); + assert.strictEqual(String(p[0].values[3]), far); + assert.strictEqual(String(p[0].values[4]), far); + }); + + it('skips entries without a hash and defaults the fee', function () { + const p = buildMempoolWritePlan([{ tx_hash: 'aa' }, { source: 'x' }, null]); + assert.strictEqual(p.length, 1); + assert.strictEqual(String(p[0].values[4]), '0'); + }); +}); + +describe('kaspa_store: evidence-driven - the survivor classes from the mutation report', function () { + + // buildWritePlan must never throw for a well-formed row set: a mutant that makes it throw is + // exactly what turns a kill into an untracked RuntimeError. + const withRow = (over) => Object.assign({ + blocks: [{ block_index: 2, block_time: 1791716119, block_hash_id: 1, previous_block_hash_id: 1 }], + transactions: [{ tx_index: 5, tx_hash_id: 1, block_index: 2, source_id: 1, destination_id: 1, + amount: '1', fee: '0', data: null, raw_data: null }], + transaction_outputs: [{ tx_index: 5, vout: 0, destination_id: 1, amount: '1' }], + dispensers: [], pubkeys: [], index_transactions: [], index_addresses: [], + }, over || {}); + + it('never throws, for every shape of a row set the ingest can hand it', function () { + const shapes = [withRow(), withRow({ index_transactions: [{ id: 1, hash: 'aa' }] }), + withRow({ index_addresses: [{ id: 1, address: 'x' }] }), + withRow({ dispensers: [{ tx_index: 5, address_id: 1, expiration: null, oracle_address_id: null, source_address_id: 1, expired_block_index: null }] }), + withRow({ pubkeys: [{ address_id: 1, pubkey: '02' }] })]; + for (const rows of shapes) { + assert.doesNotThrow(() => buildWritePlan(rows, { sinceBlockIndex: 0, events: [] }), + 'a valid row set must produce a plan, not an exception'); + } + }); + + it('survives a row set with the optional arrays missing entirely (the || [] defaults)', function () { + const bare = { blocks: withRow().blocks, transactions: withRow().transactions }; + const plan = buildWritePlan(bare, { sinceBlockIndex: 0, events: [] }); + assert.ok(plan.length >= 2, 'missing arrays mean fewer statements, not a crash'); + assert.ok(plan.every((s) => typeof s.sql === 'string' && s.sql.startsWith('INSERT INTO `'))); + const empty = buildWritePlan({}, { sinceBlockIndex: 0, events: [] }); + assert.deepStrictEqual(empty, [], 'no rows at all means no statements at all'); + }); + + it('excludes work at or below the write floor, and includes what is above it', function () { + const rows = withRow({ + blocks: [{ block_index: 7, block_time: 1, block_hash_id: 1, previous_block_hash_id: 1 }], + transactions: [{ tx_index: 9, tx_hash_id: 1, block_index: 7, source_id: 1, destination_id: 1, amount: '1', fee: '0', data: null, raw_data: null }], + }); + const atFloor = buildWritePlan(rows, { sinceBlockIndex: 7 }); + assert.deepStrictEqual(atFloor, [], 'a block AT the floor is already written'); + const above = buildWritePlan(rows, { sinceBlockIndex: 6 }); + assert.strictEqual(above.filter((s) => s.sql.startsWith('INSERT INTO `blocks`')).length, 1); + assert.strictEqual(above.filter((s) => s.sql.startsWith('INSERT INTO `transactions`')).length, 1); + }); + + it('marks EVERY table idempotent, not just the lookup ones', function () { + // The upsert flags on blocks/transactions/transaction_outputs are the fix the real-database + // test demanded; a mutant that drops one of them must fail here, not silently replay into + // a duplicate-key error. + const rows = withRow({ + index_transactions: [{ id: 1, hash: 'aa' }], index_addresses: [{ id: 1, address: 'x' }], + dispensers: [{ tx_index: 5, address_id: 1, expiration: null, oracle_address_id: null, source_address_id: 1, expired_block_index: null }], + pubkeys: [{ address_id: 1, pubkey: '02' }], + }); + const plan = buildWritePlan(rows, { sinceBlockIndex: 0, events: [] }); + const tables = ['blocks', 'transactions', 'transaction_outputs', 'dispensers', 'pubkeys', 'index_transactions', 'index_addresses']; + for (const name of tables) { + const mine = plan.filter((s) => s.sql.startsWith('INSERT INTO `' + name + '`')); + assert.ok(mine.length >= 1, name + ' must appear in the plan'); + for (const s of mine) assert.ok(s.sql.includes('ON DUPLICATE KEY UPDATE'), name + ' must be replay-safe'); + } + const events = buildWritePlan({}, { sinceBlockIndex: 0, events: [{ time: 't', code: 'REORG', data: '[]' }] }); + assert.strictEqual(events.length, 1, 'events are append-only and carry no upsert'); + assert.ok(!events[0].sql.includes('ON DUPLICATE KEY UPDATE')); + }); +}); + +describe('kaspa_store: buildWritePlan - the defaults and filters no test used to exercise', function () { + const store = require('../../../../src/chain/kaspa/kaspa_store.js'); + const plan = (rows, opts) => store.buildWritePlan(rows, opts); + const ids = (p) => JSON.stringify(p.values); + // Column names come from the module's own SCHEMA (blocks: block_index/block_time/block_hash_id/ + // previous_block_hash_id; transactions: tx_index/tx_hash_id/...; events: time/code/data), so the + // fixtures here must use those names or every value would land as null. + const block = (i) => ({ block_index: i, block_time: 1700000000 + i, block_hash_id: 'h' + i, previous_block_hash_id: 'h' + (i - 1) }); + const txrow = (bi, ti) => ({ block_index: bi, tx_index: ti, tx_hash_id: 't' + bi + '-' + ti }); + + it('emits ONLY what the rows actually contain (the || [] defaults must not invent statements)', function () { + const p = plan({ blocks: [block(6)] }, { sinceBlockIndex: 5 }); + assert.strictEqual(p.length, 1, 'one new block, nothing else in rows: got ' + p.length + ' statements'); + assert.ok(ids(p[0]).includes('h6'), 'and it is that block'); + }); + + it('filters by the cursor: a row exactly AT the cursor is excluded, one above it is not', function () { + // The mutation this kills: Number(x) > Number(since) turning into >= would let the row at the + // cursor through, and 'true' would let the older one through. + const p = plan({ blocks: [block(4), block(5), block(6)] }, { sinceBlockIndex: 5 }); + assert.strictEqual(p.length, 1, 'only block 6 is newer than the cursor (block 5 is AT it): ' + p.length); + assert.ok(ids(p[0]).includes('h6'), 'and no older block leaked in'); + }); + + it('emits index rows when they are present (the && [] mutation would silently drop them)', function () { + const rows = { blocks: [block(6)], index_transactions: [{ id: 'idx-1', hash: 'idxhash-SEND' }], index_addresses: [{ id: 'idx-2', address: 'addr-a' }], pubkeys: [{ address_id: 'addr-a', pubkey: 'pubkey-k' }] }; + const p = plan(rows, { sinceBlockIndex: 5 }); + const all = p.map(ids).join(' '); + assert.ok(all.includes('idxhash-SEND'), 'index_transactions row must be written'); + assert.ok(all.includes('addr-a'), 'index_addresses row must be written'); + assert.ok(all.includes('pubkey-k'), 'pubkeys row must be written'); + assert.strictEqual(p.length, 4, 'block + 3 index rows, no more: ' + p.length); + }); + + it('gates transactions, outputs and dispensers on the NEW transaction set', function () { + const rows = { blocks: [block(6)], + transactions: [txrow(4, 1), txrow(6, 2)], // tx 1 is old, tx 2 is new + transaction_outputs: [{ tx_index: 1, vout: 0, destination_id: 'd-old', amount: '1' }, { tx_index: 2, vout: 0, destination_id: 'd-new', amount: '2' }], + dispensers: [{ tx_index: 1, address_id: 'disp-old', expiration: 1 }, { tx_index: 2, address_id: 'disp-new', expiration: 2 }] }; + const p = plan(rows, { sinceBlockIndex: 5 }); + const all = p.map(ids).join(' '); + assert.ok(all.includes('t6-2'), 'the new transaction is written'); + assert.ok(!all.includes('t4-1'), 'the old transaction is NOT written'); + assert.ok(all.includes('d-new') && !all.includes('d-old'), 'the new output is written, the old one is not'); + assert.ok(all.includes('disp-new') && !all.includes('disp-old'), 'the new dispenser registration is written, the old one is not'); + }); + + it('emits events even when there is no block delta (a removal-only reorg has none)', function () { + // The bug this pins: an earlier version returned [] before the events loop, so the reorg + // witness was advanced over events that were never written. + const p = plan({ blocks: [], transactions: [] }, { sinceBlockIndex: 9, events: [{ id: 7, code: 'REORG' }] }); + assert.strictEqual(p.length, 1, 'the event survives a block-less tick: ' + p.length); + assert.ok(ids(p[0]).includes('REORG'), 'and it is the reorg witness'); + }); +}); + +describe('kaspa_store: buildWritePlan - idempotence and the row filters', function () { + const store = require('../../../../src/chain/kaspa/kaspa_store.js'); + const block = (i) => ({ block_index: i, block_time: 1700000000 + i, block_hash_id: 'h' + i, previous_block_hash_id: 'h' + (i - 1) }); + const txrow = (bi, ti) => ({ block_index: bi, tx_index: ti, tx_hash_id: 't' + bi + '-' + ti }); + const plan = (rows, opts) => store.buildWritePlan(rows, opts); + + it('the three data-carrying statements must be idempotent (they are the ones that stopped the reader)', function () { + // Real-database finding: without ON DUPLICATE KEY UPDATE, replaying a slice that is already + // in the table died on a duplicate primary key, and that error is not in the recoverable + // class - so the reader stopped. These three carry the block's and the transaction's own + // data, so each of them must upsert. + const p = plan({ blocks: [block(6)], transactions: [txrow(6, 2)], + transaction_outputs: [{ tx_index: 2, vout: 0, destination_id: 'd', amount: '2' }] }, { sinceBlockIndex: 5 }); + assert.strictEqual(p.length, 3, 'block + transaction + output: ' + p.length); + const upserts = p.filter((x) => /ON DUPLICATE KEY UPDATE/.test(x.sql)); + assert.strictEqual(upserts.length, 3, 'all three must carry the upsert arm, not just some: ' + upserts.length); + assert.ok(p.every((x) => /^INSERT INTO/.test(x.sql)), 'and they are inserts, not deletes'); + }); + + it('a transaction whose row appears once cannot be written twice (the Set, not an array)', function () { + // newTx is a Set built from the new transactions. If it became an array or the map() lost its + // filter, a repeated tx_index would produce a repeated statement - a duplicate insert. + // newTx is a Set built from the new transactions: it decides WHICH rows are written, it does + // not deduplicate the input. Ingest emits one row per transaction, so a repeated tx_index in + // rows is unreachable in practice - recorded here so the Set mutating to an array (or losing + // its filter) is still detectable: the old-block gate must keep holding. + const p = plan({ blocks: [block(6)], transactions: [txrow(4, 1), txrow(6, 2)] }, { sinceBlockIndex: 5 }); + const all = JSON.stringify(p.map((x) => x.values)); + assert.ok(!all.includes('t4-1'), 'the old transaction is gated out even when it is in rows'); + assert.ok(all.includes('t6-2'), 'and the new one is written'); + assert.strictEqual(p.length, 2, 'one block and one transaction: ' + p.length); + }); + + it('rows at or below the cursor are dropped from every filtered table', function () { + // The excluded row gets a marker that cannot appear anywhere else: previous_block_hash_id of + // the NEXT block is 'h5', so grepping for that string would have matched a legitimate value. + const marker = (i) => ({ block_index: i, block_time: 1700000000 + i, block_hash_id: 'MARK-' + i, previous_block_hash_id: 'PREV-' + i }); + const p = plan({ blocks: [marker(5), marker(6)], transactions: [txrow(5, 1), txrow(6, 2)], + transaction_outputs: [{ tx_index: 1, vout: 0, destination_id: 'out-old' }, { tx_index: 2, vout: 0, destination_id: 'out-new' }], + dispensers: [{ tx_index: 1, address_id: 'disp-old' }, { tx_index: 2, address_id: 'disp-new' }] }, + { sinceBlockIndex: 5 }); + const all = JSON.stringify(p.map((x) => x.values)); + assert.ok(!all.includes('MARK-5') && !all.includes('PREV-5'), 'the block AT the cursor is not written'); + assert.ok(!all.includes('t5-1'), 'the transaction at the cursor is not written'); + assert.ok(!all.includes('out-old') && !all.includes('disp-old'), 'neither is its output or dispenser'); + assert.ok(all.includes('MARK-6') && all.includes('t6-2') && all.includes('out-new') && all.includes('disp-new'), 'the newer ones are'); + }); +}); + +describe('kaspa_store: the contracts outside the write plan', function () { + const store = require('../../../../src/chain/kaspa/kaspa_store.js'); + + it('SCHEMA pins the exact column list every INSERT is built from (silent column drift is the failure this denies)', function () { + // These lists are the decoder-to-indexer contract: a renamed or reordered column is not a style + // question, it is a row that lands in the wrong place. Pinned verbatim on purpose. + assert.deepStrictEqual(store.SCHEMA, { + index_transactions: ['id', 'hash'], + index_addresses: ['id', 'address'], + blocks: ['block_index', 'block_time', 'block_hash_id', 'previous_block_hash_id'], + transactions: ['tx_index', 'tx_hash_id', 'block_index', 'source_id', 'destination_id', 'amount', 'fee', 'data', 'raw_data'], + transaction_outputs: ['tx_index', 'vout', 'destination_id', 'amount'], + pubkeys: ['address_id', 'pubkey'], + dispensers: ['tx_index', 'address_id', 'expiration', 'oracle_address_id', 'source_address_id', 'expired_block_index'], + events: ['time', 'code', 'data'], + mempool_transactions: ['tx_hash', 'source', 'destination', 'amount', 'fee', 'data', 'raw_data'], + }); + }); + + it('insertSql: unknown table refuses, missing column becomes NULL, values follow column order', function () { + assert.throws(() => store.insertSql('no_such_table', {}), /unknown table/, 'an unknown table must refuse, not build'); + const { sql, values } = store.insertSql('blocks', { block_index: 7 }); + assert.ok(/^INSERT INTO `blocks` \(/.test(sql), 'table is quoted: ' + sql.slice(0, 40)); + assert.ok(/\(\?, \?, \?, \?\) VALUES/.test(sql) || /\) VALUES \(\?/.test(sql), 'one placeholder per column'); + assert.strictEqual(values.length, 4, 'one value per column'); + assert.strictEqual(values[0], 7, 'first value is the block_index we passed'); + assert.strictEqual(values[1], null, 'a column that was not supplied is NULL, never undefined'); + }); + + it('the upsert arm exists when asked for, and never overwrites the id', function () { + const plain = store.insertSql('index_addresses', { id: 1, address: 'a' }); + assert.ok(!/ON DUPLICATE/.test(plain.sql), 'no upsert arm unless it is asked for'); + const up = store.insertSql('index_addresses', { id: 1, address: 'a' }, { upsert: true }); + assert.ok(/ON DUPLICATE KEY UPDATE/.test(up.sql), 'the upsert arm is present when asked for'); + const arm = up.sql.slice(up.sql.indexOf('ON DUPLICATE KEY UPDATE')); + assert.ok(/`address` = VALUES\(`address`\)/.test(arm), 'the arm updates the other columns'); + assert.ok(!/`id` = VALUES/.test(arm), 'and never the id - that is what makes a replay idempotent'); + }); + + it('withTransaction: passes through without transaction support, commits on success, rolls back and rethrows on failure', async function () { + const passthrough = { calls: [] }; + const r1 = await store.withTransaction(passthrough, async () => 'x'); + assert.strictEqual(r1, 'x', 'a connection without begin/commit still runs the work'); + + const ok = { calls: [], async beginTransaction() { this.calls.push('begin'); }, async commit() { this.calls.push('commit'); }, async rollback() { this.calls.push('rollback'); } }; + assert.strictEqual(await store.withTransaction(ok, async () => 5), 5); + assert.deepStrictEqual(ok.calls, ['begin', 'commit'], 'committed exactly once, no rollback'); + + const bad = { calls: [], async beginTransaction() { this.calls.push('begin'); }, async commit() { this.calls.push('commit'); }, async rollback() { this.calls.push('rollback'); } }; + const boom = new Error('write failed'); + await assert.rejects(() => store.withTransaction(bad, async () => { throw boom; }), /write failed/, 'the original failure is rethrown'); + assert.deepStrictEqual(bad.calls, ['begin', 'rollback'], 'rolled back exactly once, no commit'); + + const worse = { calls: [], async beginTransaction() {}, async commit() {}, async rollback() { throw new Error('rollback also failed'); } }; + await assert.rejects(() => store.withTransaction(worse, async () => { throw boom; }), (error) => { + assert.strictEqual(error, boom, 'the ORIGINAL error is what surfaces'); + assert.ok(/rollback also failed/.test(String(error.rollbackError)), 'and the rollback failure rides along'); + return true; + }); + }); + + it('toIdempotent: DROP is dropped, CREATE becomes IF NOT EXISTS, anything else is untouched', function () { + assert.strictEqual(store.toIdempotent('DROP TABLE IF EXISTS `blocks`;'), null, 'a DROP would empty the database on every start'); + assert.strictEqual(store.toIdempotent('CREATE TABLE `blocks` ('), 'CREATE TABLE IF NOT EXISTS `blocks` (', 'CREATE is made repeatable'); + assert.strictEqual(store.toIdempotent('INSERT INTO `events` VALUES (1)'), 'INSERT INTO `events` VALUES (1)', 'other statements pass through'); + }); + + it('buildMempoolWritePlan: an entry without a hash is skipped, and defaults are NULL not undefined', function () { + const p = store.buildMempoolWritePlan([ + { tx_hash: 'h1', source: 's', destination: 'd', amount: '1', fee: '0', data: 'x', raw_data: 'r' }, + { source: 'no-hash-at-all' }, + null, + ]); + assert.strictEqual(p.length, 1, 'only the entry with a hash becomes a statement: ' + p.length); + assert.ok(JSON.stringify(p[0].values).includes('h1'), 'and it is that entry'); + const p2 = store.buildMempoolWritePlan([{ tx_hash: 'h2' }]); + assert.strictEqual(p2.length, 1); + assert.ok(!JSON.stringify(p2[0].values).includes('undefined'), 'absent fields are NULL, never undefined'); + }); + + it('buildRollbackPlan: refuses a nonsense cursor, and deletes children before parents', function () { + assert.throws(() => store.buildRollbackPlan(-1), /non-negative/, 'a negative cursor is refused'); + assert.throws(() => store.buildRollbackPlan('not a number'), /non-negative/, 'so is a non-number'); + const plan = store.buildRollbackPlan(42); + assert.ok(plan.length >= 3, 'several statements, children first: ' + plan.length); + plan.forEach((s) => assert.ok(Array.isArray(s.values) && s.values[0] === 42, 'every statement carries the cursor')); + const sql = plan.map((s) => s.sql).join('\n'); + assert.ok(sql.indexOf('transaction_outputs') < sql.indexOf('`transactions`'), 'outputs are deleted before their parent transactions'); + }); +}); diff --git a/test/unit/chain/kaspa/kaspa_sync.test.js b/test/unit/chain/kaspa/kaspa_sync.test.js new file mode 100644 index 00000000..c09a497a --- /dev/null +++ b/test/unit/chain/kaspa/kaspa_sync.test.js @@ -0,0 +1,134 @@ +/********************************************************************* + * + * 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_sync part: the wiring, not the ingest. + * + * The ingest itself is covered by kaspa_block_decoder.test.js plus the family + * suite in the reference implementation. What this file pins is the part + * contract: it must refuse to start on a decoder configured for another + * wireFormat, refuse to start twice, report null stats when idle (a normal + * state, not an error), and stop gracefully — the in-flight tick completes. + * + * No network and no platform module graph: the part is driven with a fake + * `this`, a fake rpc and a fake db, which is also what makes it runnable in + * this repo's test job without pulling bitcoinjs in. + **********************************************************************/ +'use strict'; + +const assert = require('assert'); + +const kaspaSync = require('../../../../src/XChainDecoder/kaspa_sync.js'); + +const EMPTY_SLICE = { removedChainBlockHashes: [], addedChainBlockHashes: [], chainBlockAcceptedTransactions: [] }; + +function fakeRpc({ slices = [EMPTY_SLICE] } = {}) { + let index = 0; + return { + calls: 0, + connected: 0, + disconnected: 0, + async connect() { this.connected++; return this; }, + async disconnect() { this.disconnected++; }, + async getBlockDagInfo() { return { pruningPointHash: 'pp'.repeat(32) }; }, + async getVirtualChainFromBlockV2() { + this.calls++; + return slices[Math.min(index++, slices.length - 1)]; + }, + }; +} + +function fakeDb() { + return { statements: [], async query(sql, values) { this.statements.push(String(sql).split(' ').slice(0, 2).join(' ')); } }; +} + +function decoderHost({ wireFormat = 'kaspa', rpc = fakeRpc() } = {}) { + return { + network: 'kaspa-testnet', + db: fakeDb(), + xchainBlockDecoder: { wireFormat }, + log() {}, + __rpc: rpc, + }; +} + +describe('kaspa_sync part', function () { + + it('exposes the three methods the class picks up via Object.assign', function () { + assert.strictEqual(typeof kaspaSync.startKaspaIngest, 'function'); + assert.strictEqual(typeof kaspaSync.stopKaspaIngest, 'function'); + assert.strictEqual(typeof kaspaSync.kaspaIngestStats, 'function'); + }); + + it('refuses to start on a decoder configured for another wireFormat', function () { + const host = decoderHost({ wireFormat: 'default' }); + assert.throws(() => kaspaSync.startKaspaIngest.call(host, { rpc: host.__rpc, maxTicks: 1 }), + /configured for wireFormat "default"/); + assert.strictEqual(host.kaspaService, undefined, 'a refused start must leave no service behind'); + }); + + it('refuses a second start instead of silently double-ingesting', function () { + const host = decoderHost(); + const service = kaspaSync.startKaspaIngest.call(host, { rpc: host.__rpc, maxTicks: 1 }); + assert.ok(service); + assert.throws(() => kaspaSync.startKaspaIngest.call(host, { rpc: host.__rpc, maxTicks: 1 }), + /already running a Kaspa ingest/); + }); + + it('reports null stats when this instance is not the Kaspa writer', function () { + assert.strictEqual(kaspaSync.kaspaIngestStats.call({}), null); + }); + + it('runs a tick and stops gracefully, disconnecting the client', async function () { + const host = decoderHost(); + const service = kaspaSync.startKaspaIngest.call(host, { rpc: host.__rpc, intervalMs: 1, maxTicks: 1 }); + assert.strictEqual(service.networkId, 'kaspa-testnet'); + await host.kaspaIngestPromise; + + assert.strictEqual(host.__rpc.connected, 1, 'the client is connected before the loop starts'); + assert.strictEqual(host.__rpc.disconnected, 1, 'and disconnected when the loop settles'); + assert.ok(host.__rpc.calls >= 1, 'the loop actually pulled a slice'); + assert.strictEqual(service.ticks, 1); + + // One tick against an empty slice writes nothing (no blocks -> no plan). READS are fine: + // init() reconciles the write floor with a SELECT even when there is no checkpoint file. + const wrote = host.db.statements.filter((s) => /^(INSERT|DELETE)/i.test(String(s.sql || s))); + assert.strictEqual(wrote.length, 0, 'an empty slice must produce no write'); + }); + + it('surfaces stats while running and stops on request', async function () { + const host = decoderHost(); + const service = kaspaSync.startKaspaIngest.call(host, { rpc: host.__rpc, intervalMs: 50 }); + const stats = kaspaSync.kaspaIngestStats.call(host); + assert.ok(stats, 'stats are available while the loop owns the chain'); + assert.strictEqual(typeof stats.ticks, 'number'); + + assert.strictEqual(await kaspaSync.stopKaspaIngest.call(host), true); + assert.strictEqual(await kaspaSync.stopKaspaIngest.call(host), false, 'stopping twice is not an error'); + assert.strictEqual(kaspaSync.kaspaIngestStats.call(host), null, 'stats go null once stopped'); + }); + + it('stop is safe when nothing was ever started', async function () { + assert.strictEqual(await kaspaSync.stopKaspaIngest.call({}), false); + }); +}); + +describe('kaspa_sync logging', function () { + it('prefers the platform logger (real levels); the decoder sink is only the fallback', function () { + assert.strictEqual(typeof kaspaSync.kaspaLogger, 'function'); + const logger = kaspaSync.kaspaLogger(function () {}); + for (const lvl of ['error', 'warn', 'info', 'debug']) assert.strictEqual(typeof logger[lvl], 'function'); + const src = require('fs').readFileSync(require('path').join(__dirname, '../../../../src/XChainDecoder/kaspa_sync.js'), 'utf8'); + assert.ok(src.includes("require('../observability/logger')"), 'platform logger must be preferred over the sink'); + }); +});