diff --git a/crypto_nodes/kaspa/ASSET-HASHES.md b/crypto_nodes/kaspa/ASSET-HASHES.md new file mode 100644 index 00000000..6d54bbec --- /dev/null +++ b/crypto_nodes/kaspa/ASSET-HASHES.md @@ -0,0 +1,42 @@ +# Kaspa node release assets and hashes (xchain-node / crypto_nodes/kaspa) + +> Rule: only hashes computed locally are recorded. Anything else is marked `TODO` and the downloader +> refuses to run (fail-closed) rather than inventing a hash to make things work. + +## Target version + +`kaspanet/rusty-kaspa` **v2.1.0** (released 2026-09-22; confirmed locally as `kaspad v2.1.0`) + +## Assets and hashes + +| Platform | Asset | sha256 | Status | +|---|---|---|---| +| linux-amd64 | `rusty-kaspa-v2.1.0-linux-amd64.zip` | — | **TODO** (TLS to that CDN failed locally: `curl exit=35`) | +| win64 | `rusty-kaspa-v2.1.0-win64.zip` | `fb25743a4b432d376c4ca49d8799769dbc6d5b070f0b502350ce32cfdb38ec0f` | computed locally with `sha256sum` | +| osx | `rusty-kaspa-v2.1.0-osx.zip` | — | **TODO** | + +**Command to fill a hash in** (run on a machine that can reach GitHub, then paste into `ASSETS` in `kaspa_node.js`): + +```bash +curl -LO https://github.com/kaspanet/rusty-kaspa/releases/download/v2.1.0/rusty-kaspa-v2.1.0-linux-amd64.zip +sha256sum rusty-kaspa-v2.1.0-linux-amd64.zip +``` + +## Relationship to `src/github_hashes.json` + +That file's shape is `owner/repo -> version -> { x86_64, aarch64 }`, where **`x86_64` means Linux**. +Only the win64 hash was computed here; putting it under `x86_64` would misrepresent a Windows +archive as a Linux one, so `src/github_hashes.json` is left untouched until the Linux hash lands. + +## Archive layout (measured) + +The release archive is **flat**: unpacking yields the executables directly (Windows included): + +``` +kaspad(.exe) full node +kaspa-wallet(.exe) interactive CLI wallet (REPL; `--help` enters the REPL, so scripting must feed stdin) +rothschild(.exe) transaction generator / load tool (supports testnet and devnet only, not simnet) +stratum-bridge(.exe) stratum bridge for pools +``` + +`crypto_nodes/kaspa/Dockerfile` relies on that flat layout: it copies ./kaspa and installs kaspad and kaspa-wallet. diff --git a/crypto_nodes/kaspa/Dockerfile b/crypto_nodes/kaspa/Dockerfile new file mode 100644 index 00000000..ea592e0b --- /dev/null +++ b/crypto_nodes/kaspa/Dockerfile @@ -0,0 +1,39 @@ +# Kaspa node image (crypto_nodes/kaspa of xchain-node) +# +# Differences from the bitcoind family (why bitcoin/ could not simply be reused): +# · kaspad is a Rust full node with no -cli, so liveness uses the wRPC port plus log freshness; +# · it is not driven by a single -conf= file, so configuration is TOML (--configfile) plus CLI overrides; +# · wRPC (Borsh/JSON) is the indexer's and SDK's interface, so it must listen explicitly. +# +# The directory convention matches the other coins: crypto_nodes/kaspa/ holds binaries that are +# already downloaded and verified (node_service checks sha256 from github_hashes.json; this only installs). + +FROM ubuntu:22.04 + +ARG CONF_FILE=kaspa-mainnet.conf + +# netcat for the HEALTHCHECK port probe; ca-certificates for runtime TLS +RUN apt-get update \ + && apt-get install -y --no-install-recommends netcat-openbsd ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# binaries: the release archive is flat (kaspad / kaspa-wallet / rothschild / stratum-bridge) +COPY ./kaspa /kaspa +WORKDIR /kaspa +RUN install -m 0755 -o root -g root -t /usr/local/bin /kaspa/kaspad \ + && (ls /kaspa/kaspa-wallet >/dev/null 2>&1 && install -m 0755 -o root -g root -t /usr/local/bin /kaspa/kaspa-wallet || true) + +COPY ./$CONF_FILE /etc/kaspa/kaspad.conf + +# liveness = the wRPC port is listening AND the log was written to in the last five minutes. +# The second half catches wedges where the process lives but stops working; a port probe alone misses +# those (kaspad usually keeps its port). The probe only reports, as with the other coins: operators decide. +# start-period is hours on purpose: a first sync downloads ~1.4M headers and hundreds of +# millions of UTXOs, and kaspad answers every RPC with an IBD error for that whole window. +# A minutes-scale start-period would declare a healthy syncing node unhealthy. The value is +# pinned to crypto_nodes/kaspa/kaspa_node.js healthStartPeriodSeconds() by a unit test. +HEALTHCHECK --interval=60s --timeout=30s --start-period=43200s --retries=3 \ + CMD sh -c 'nc -z 127.0.0.1 ${KASPAD_WRPC_BORSH_PORT:-17110} && find /data/kaspa/logs -type f -mmin -5 | grep -q .' || exit 1 + +# --yes is for non-interactive use (required in a container); the rest comes from TOML +CMD ["kaspad","--configfile=/etc/kaspa/kaspad.conf","--yes"] diff --git a/crypto_nodes/kaspa/kaspa-mainnet.conf b/crypto_nodes/kaspa/kaspa-mainnet.conf new file mode 100644 index 00000000..b501f4a5 --- /dev/null +++ b/crypto_nodes/kaspa/kaspa-mainnet.conf @@ -0,0 +1,31 @@ +# Kaspa node configuration (mainnet) - TOML for the file, CLI for overrides +# +# Keys must be kaspad's kebab-case field names: it deserializes with serde(deny_unknown_fields), +# so a misspelled key refuses to start. That is the good kind of failure. +# +# Ports come from the help text in kaspad/src/args.rs: +# gRPC 16110 · P2P 16111 · wRPC-Borsh 17110 · wRPC-JSON 18110 +# +# Notes: +# utxoindex - the indexer and wallets look UTXOs up by address, so this must be on +# archival - required by the indexer: KIP-15 keeps roughly 30 hours of transactions and +# block headers, so a full action history needs archiving. Heavy on disk. +# appdir - data directory (a volume inside the container) + +appdir = "/data/kaspa" +logdir = "/data/kaspa/logs" + +# look UTXOs up by address (Kaspa's utxoindex) +utxoindex = true + +# full archive: on for the indexer, off for wallet/relay only +archival = true + +# UPnP is not needed in a container (measured: it spams errors) +disable-upnp = true + +# listen: exposed inside the container; docker port mapping decides reachability +rpclisten = "0.0.0.0:16110" +rpclisten-borsh = "0.0.0.0:17110" +rpclisten-json = "0.0.0.0:18110" +listen = "0.0.0.0:16111" diff --git a/crypto_nodes/kaspa/kaspa-simnet.conf b/crypto_nodes/kaspa/kaspa-simnet.conf new file mode 100644 index 00000000..272d1d24 --- /dev/null +++ b/crypto_nodes/kaspa/kaspa-simnet.conf @@ -0,0 +1,27 @@ +# Kaspa node configuration (simnet) +# +# The simnet default ports are unverified (the args.rs help only lists mainnet and testnet). +# The four ports below are what this project measured and chose, not node defaults - explicit +# by convention. Anything that talks to xchain-node's auto-configuration must pass them +# explicitly: portsFor in kaspa_node.js refuses to return a default for simnet on purpose. +# +# Measured locally (simnet bootstrap, 2026-10-11): +# · starting from genesis requires `--enable-unsynced-mining` +# (args.rs, verbatim: "required when initiating a new network from genesis") +# · no PoW (params.rs: simnet skip_proof_of_work=true); mining = getBlockTemplate + submitBlock; +# `extraData` must be a byte array `[]`; `allowNonDaaBlocks: true` is a sibling of block +# · coinbase maturity is 1000 blocks (not 100); simnet does not mine by itself, so transactions sit in the mempool +# · the shipped rothschild supports testnet/devnet only, so simnet needs its own block producer + +appdir = "/data/kaspa" +logdir = "/data/kaspa/logs" + +simnet = true +utxoindex = true +disable-upnp = true + +# Non-default values: this project's convention (see the file header) +rpclisten = "0.0.0.0:16510" +rpclisten-borsh = "0.0.0.0:17510" +rpclisten-json = "0.0.0.0:18510" +listen = "0.0.0.0:16511" diff --git a/crypto_nodes/kaspa/kaspa-testnet.conf b/crypto_nodes/kaspa/kaspa-testnet.conf new file mode 100644 index 00000000..c50375ed --- /dev/null +++ b/crypto_nodes/kaspa/kaspa-testnet.conf @@ -0,0 +1,25 @@ +# Kaspa node configuration (testnet = TN-10) +# +# Ports come from the help text in kaspad/src/args.rs: +# gRPC 16210 · P2P 16211 · wRPC-Borsh 17210 · wRPC-JSON 18210 +# (note: the two wRPC ports are separate per network, not shared) +# +# Measured TN-10 facts (2026-10-11, kaspad v2.1.0): +# · the default suffix is 10 (`--netsuffix` default); `--testnet` is TN-10 +# · first sync scale: 248,699 chain heads to the pruning point, then 1,476,465 IBD headers, then the UTXO set +# (the UTXO set takes very long: 260M+ entries locally and still going, with every RPC returning +# "consensus is currently in a transitional ibd state") +# · so for the indexer, use an archival node and leave --retention-period-days to operations + +appdir = "/data/kaspa" +logdir = "/data/kaspa/logs" + +testnet = true +utxoindex = true +archival = true +disable-upnp = true + +rpclisten = "0.0.0.0:16210" +rpclisten-borsh = "0.0.0.0:17210" +rpclisten-json = "0.0.0.0:18210" +listen = "0.0.0.0:16211" diff --git a/crypto_nodes/kaspa/kaspa_node.js b/crypto_nodes/kaspa/kaspa_node.js new file mode 100644 index 00000000..a74bc6e1 --- /dev/null +++ b/crypto_nodes/kaspa/kaspa_node.js @@ -0,0 +1,190 @@ +/********************************************************************* + * XChain Kaspa Node - crypto_node module + * + * 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. + * + * + * Why this is a module of its own: the platform's crypto_nodes/* and node_service/* are all + * written for the bitcoind family (-conf= startup, -cli liveness, base58 addresses), + * while Kaspa is a Rust full node (kaspad) with different flags, ports and liveness. + * This module keeps that surface as pure functions, so it can be tested offline. + * + * Only VERIFIED facts are encoded (each one carries its source); anything unverified is left + * blank and fails closed: + * - ports: mainnet/testnet come from the help text of kaspad/src/args.rs; simnet defaults + * are UNVERIFIED, so they must be passed explicitly - never guessed; + * - hashes: win64 was computed locally (sha256sum); linux-amd64 was blocked, so null - and + ********************************************************************/ +'use strict'; + +const REPO = 'kaspanet/rusty-kaspa'; +const VERSION = 'v2.1.0'; + +const ASSETS = { + 'linux-amd64': { + file: 'rusty-kaspa-v2.1.0-linux-amd64.zip', + sha256: null, // TODO: this host cannot reach that CDN (SSL 35). See ASSET-HASHES.md + note: 'the one the Docker image uses' + }, + 'win64': { + file: 'rusty-kaspa-v2.1.0-win64.zip', + sha256: 'fb25743a4b432d376c4ca49d8799769dbc6d5b070f0b502350ce32cfdb38ec0f', // computed locally with sha256sum + note: 'for local development and debugging' + }, + 'osx': { + file: 'rusty-kaspa-v2.1.0-osx.zip', + sha256: null, // TODO + note: '' + } +}; + +// Measured from the help text of kaspad/src/args.rs: +// "gRPC connections (default port: 16110, testnet: 16210)" +// "wRPC Borsh connections (default port: 17110, testnet: 17210)" +// "wRPC JSON connections (default port: 18110, testnet: 18210)" +// "listen for connections (default all interfaces port: 16111, testnet: 16211)" +const PORTS = { + mainnet: { grpc: 16110, p2p: 16111, wrpcBorsh: 17110, wrpcJson: 18110 }, + testnet: { grpc: 16210, p2p: 16211, wrpcBorsh: 17210, wrpcJson: 18210 }, + simnet: null // UNVERIFIED: ports must be passed explicitly, never guessed +}; + +function assetFor(platform) { + const a = ASSETS[platform]; + if (!a) throw new Error('unknown platform: ' + platform + ' (choose one of: ' + Object.keys(ASSETS).join(', ') + ')'); + return a; +} + +function downloadUrl(platform) { + return 'https://github.com/' + REPO + '/releases/download/' + VERSION + '/' + assetFor(platform).file; +} + +// fail-closed: no trusted hash means no download (offline verification is the whole point) +function assertHashKnown(platform) { + const a = assetFor(platform); + if (!a.sha256) { + throw new Error('refusing to download ' + platform + ': no registered sha256 (hashes are never invented). ' + + 'Run `sha256sum ' + a.file + '` on a host that can reach GitHub, then fill it in here'); + } + return a.sha256; +} + +function verifySha256(actualHex, expectedHex) { + const a = String(actualHex || '').trim().toLowerCase(); + const e = String(expectedHex || '').trim().toLowerCase(); + if (a.length !== 64 || e.length !== 64) throw new Error('sha256 must be 64 hex characters'); + return a === e; +} + +function portsFor(network, explicit) { + if (explicit) return Object.assign({}, explicit); + const p = PORTS[network]; + if (!p) throw new Error('default ports for network=' + network + ' are unverified; pass ports explicitly'); + return Object.assign({}, p); +} + +// Only flags that were MEASURED (the CLI and help text of args.rs) +function buildRunArgs({ network, appdir, ports, archival = false, utxoindex = true, allowUnsyncedMining = false, extra = [] }) { + if (!['mainnet', 'testnet', 'simnet', 'devnet'].includes(network)) throw new Error('unknown network: ' + network); + if (!appdir) throw new Error('appdir is required'); + const p = portsFor(network, ports); + const args = []; + if (network === 'testnet') args.push('--testnet'); // default suffix 10 (args.rs: netsuffix 10) + if (network === 'simnet') args.push('--simnet'); + if (network === 'devnet') args.push('--devnet'); + args.push('--appdir=' + appdir); + if (utxoindex) args.push('--utxoindex'); + if (archival) args.push('--archival'); // the decoder needs history (KIP-15 keeps ~30h) + if (allowUnsyncedMining) args.push('--enable-unsynced-mining'); + args.push('--rpclisten=127.0.0.1:' + p.grpc); + args.push('--rpclisten-borsh=127.0.0.1:' + p.wrpcBorsh); + args.push('--rpclisten-json=127.0.0.1:' + p.wrpcJson); + args.push('--listen=127.0.0.1:' + p.p2p); + args.push('--yes'); // non-interactive (required in a container) + args.push('--disable-upnp'); // no UPnP in a container (measured: it errors) + return args.concat(extra); +} + +// Liveness, for the Docker HEALTHCHECK. kaspad has no -cli, so it uses +// (a) the wRPC port is listening, and (b) the log directory saw a new file within N +function buildHealthProbe({ wrpcBorshPort, logDir, freshMinutes = 5 }) { + if (!wrpcBorshPort) throw new Error('wrpcBorshPort is required'); + const parts = ['sh', '-c']; + const cmd = 'nc -z 127.0.0.1 ' + wrpcBorshPort + + (logDir ? ' && find ' + logDir + ' -type f -mmin -' + freshMinutes + ' | grep -q .' : ''); + parts.push(cmd); + return parts; +} + +// -- The three places where the missing -cli changes the shape (every other coin has it) -- +// The platform's bootstrap_node_tip_guard takes the tip from `-cli -conf=... getblockcount`; +// kaspad has no such CLI, so this uses wRPC-JSON instead. +// Measured: ws://127.0.0.1:/json accepts JSON-RPC directly (see the 11.8 smoke test). +function tipGuardPlan({ wrpcJsonPort, network }) { + if (!wrpcJsonPort) throw new Error('wrpcJsonPort is required'); + const p = portsFor(network, network === 'simnet' ? { wrpcJson: wrpcJsonPort } : undefined); + const port = p.wrpcJson || wrpcJsonPort; + return { + url: 'ws://127.0.0.1:' + port + '/json', + method: 'getBlockDagInfo', + params: {}, + // Tip: sink (the currently selected tip) first, falling back to pruningPointHash for reachability + pick: ["sink", "pruningPointHash"], + note: 'replaces `-cli getblockcount`; kaspad has no CLI (measured)' + }; +} + +function remoteVersionProbe({ wrpcJsonPort }) { + if (!wrpcJsonPort) throw new Error('wrpcJsonPort is required'); + return { + url: 'ws://127.0.0.1:' + wrpcJsonPort + '/json', + method: 'getServerInfo', + params: {}, + // version_service reads it to decide the remote node version + pick: ['serverVersion'], + note: 'replaces `-cli getnetworkinfo`; returns something like "2.1.0" (measured)' + }; +} + +// The Docker HEALTHCHECK start-period. +// Not minutes: a measured TN-10 first sync downloads 1,476,465 headers plus hundreds of +// millions of UTXOs, and every RPC answers "consensus is currently in a transitional ibd +// state" for its whole duration, so a minute-scale start-period would call a healthy node +function healthStartPeriodSeconds({ network, archival = false, firstSync = true } = {}) { + if (!firstSync) return 600; // a snapshot/data dir is already in place: starts fast + if (network === 'simnet') return 120; // no PoW and no peers + // Mainnet/testnet first sync: hours; archival gets double that (it rebuilds history) + return archival ? 6 * 3600 * 2 : 6 * 3600; +} + +// -- A stable "is it still in IBD" test (corrected after measuring the wrong one) ---------──── +// Measured: one simnet node read isSynced=true while mining and false once idle, so a bare +// !isSynced calls a HEALTHY node "still loading". Progress comes first, isSynced second: +// - headerCount/blockCount grew between probes: genuinely catching up (true); +// - unchanged across both probes: caught up (false); +// - too few samples or jittery numbers: conservatively true (let the caller retry). +function judgeInitialBlockDownload({ first, second, isSynced } = {}) { + if (!first || !second) return { initialblockdownload: true, reason: 'not enough samples; waiting conservatively' }; + const h1 = Number(first.headerCount || 0), h2 = Number(second.headerCount || 0); + const b1 = Number(first.blockCount || 0), b2 = Number(second.blockCount || 0); + if (h2 > h1 || b2 > b1) return { initialblockdownload: true, reason: 'headerCount/blockCount still growing' }; + if (h2 === h1 && b2 === b1) return { initialblockdownload: false, reason: 'no growth across two probes (caught up)' }; + if (isSynced === true) return { initialblockdownload: false, reason: 'isSynced=true' }; + return { initialblockdownload: true, reason: 'values unstable; waiting conservatively' }; +} + +module.exports = { + REPO, VERSION, ASSETS, PORTS, + assetFor, downloadUrl, assertHashKnown, verifySha256, + portsFor, buildRunArgs, buildHealthProbe, + tipGuardPlan, remoteVersionProbe, healthStartPeriodSeconds, + judgeInitialBlockDownload +}; 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/src/services/bootstrap_node_tip_guard.js b/src/services/bootstrap_node_tip_guard.js index c85b5b89..6e825863 100644 --- a/src/services/bootstrap_node_tip_guard.js +++ b/src/services/bootstrap_node_tip_guard.js @@ -102,12 +102,71 @@ function defaultRunner(cmd, args) { // { blocks, headers, initialblockdownload } from the coin node container's own // CLI. Throws with a reason when the container is missing or the daemon does // not answer (still loading, RPC not up yet). -async function readCoinNodeChainInfo(coin, network, { runner = defaultRunner, getModuleContainer } = {}) { + // Kaspa has no -cli (v2.1.0 ships only kaspad / kaspa-wallet / rothschild / stratum-bridge), + // so chain info is read from the host side on the container's wRPC-JSON port: the mapping comes from `docker port`, + // and the request shape and field mapping are pinned by tests in crypto_nodes/kaspa/kaspa_node.js. + // field mapping: blocks <- blockCount (blue score), headers <- headerCount, + // initialblockdownload <- !isSynced (measured: every RPC returns an ibd error while syncing) + function kaspaRpc(url, method, params = {}) { + const WebSocket = require('ws') + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + const timer = setTimeout(() => { + try { ws.terminate() } catch (e) { /* a failed close must not mask the error */ } + reject(new Error(method + ' timed out (no response from ws://...)')) + }, 15000) + ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }))) + ws.on('message', (data) => { + clearTimeout(timer) + try { + const json = JSON.parse(String(data)) + ws.close() + if (json.error) reject(new Error(method + ': ' + JSON.stringify(json.error).slice(0, 160))) + else resolve(json.params !== undefined ? json.params : json.result) + } catch (error) { + ws.close() + reject(error) + } + }) + ws.on('error', (error) => { clearTimeout(timer); reject(error) }) + }) + } + + async function readKaspaChainInfo(containerId, network, runner) { + const kaspaNode = require('../../crypto_nodes/kaspa/kaspa_node.js') + // the simnet default ports are unverified, so they must be passed explicitly (module convention) + const ports = kaspaNode.portsFor(network, network === 'simnet' ? { wrpcJson: 18510 } : undefined) + const { stdout } = await runner('docker', ['port', containerId, String(ports.wrpcJson)]) + const match = String(stdout).match(/:(\d+)\s*$/) + if (!match) throw new Error('Kaspa container has no wRPC-JSON port mapped ' + ports.wrpcJson + '(docker port returned nothing)') + const plan = kaspaNode.tipGuardPlan({ wrpcJsonPort: Number(match[1]), network }) + // two probes bracketing an observation window: isSynced alone is not trustworthy + // (an idle node read back false), so progress in headerCount/blockCount decides; pinned by unit tests + const first = await kaspaRpc(plan.url, plan.method, plan.params) + await new Promise((resolve) => setTimeout(resolve, 3000)) + const second = await kaspaRpc(plan.url, plan.method, plan.params) + const server = await kaspaRpc(plan.url, 'getServerInfo', {}) + const verdict = kaspaNode.judgeInitialBlockDownload({ + first: { headerCount: first.headerCount, blockCount: first.blockCount }, + second: { headerCount: second.headerCount, blockCount: second.blockCount }, + isSynced: server.isSynced + }) + return { + blocks: Number(second.blockCount !== undefined ? second.blockCount : (second.blueScore || 0)), + headers: Number(second.headerCount !== undefined ? second.headerCount : 0), + initialblockdownload: verdict.initialblockdownload + } + } + + async function readCoinNodeChainInfo(coin, network, { runner = defaultRunner, getModuleContainer } = {}) { const cliArgs = NODE_CLI_ARGS[coin] - if (!cliArgs) throw new Error(`no node CLI known for coin ${coin}`) + // Kaspa has no -cli, so it goes through the host-side wRPC query; other coins keep their path + const isKaspa = coin === 'kaspa' + if (!cliArgs && !isKaspa) throw new Error(`no node CLI known for coin ${coin}`) const lookup = getModuleContainer || require('../state').db.getModuleContainer.bind(require('../state').db) const containerId = await lookup(NODE_MODULE_NAME, coin, network) if (!containerId) throw new Error(`no ${coin}/${network} node container is registered`) + if (isKaspa) return await readKaspaChainInfo(containerId, network, runner) const { stdout } = await runner('docker', ['exec', containerId, ...cliArgs, 'getblockchaininfo']) let info try { diff --git a/src/services/node_service/crypto_node_download.js b/src/services/node_service/crypto_node_download.js index 95f0ac4c..39a61d3a 100644 --- a/src/services/node_service/crypto_node_download.js +++ b/src/services/node_service/crypto_node_download.js @@ -229,14 +229,91 @@ async function getCryptoNode(coin, network, version) { await gitHubDownloader.downloadRepoVersion("dogecoin", "dogecoin", version, { outputPath: cryptoNodesDir + "/dogecoin" }) } else if (coin === Coin.LITECOIN) { await gitHubDownloader.downloadRepoVersion("litecoin-project", "litecoin", version, { outputPath: cryptoNodesDir + "/litecoin" }) + } else if (coin === Coin.KASPA) { + await getKaspaNode(version) } else { throw new Error("There's no support for " + coin + " in " + network + " network yet") } return true } +// ── Kaspa ──────────────────────────────────────────────────────────────────── +// three differences from the other coins, each pinned by tests in crypto_nodes/kaspa/kaspa_node.js: +// 1. the release asset is a .zip (other coins ship .tar.gz), so decompressTarGz does not apply; +// the system unzip is used (the image has it) and a missing one fails loudly. +// 2. the repository is kaspanet/rusty-kaspa, not /. +// 3. sha256 verification is fail-closed: an unrecorded hash refuses the download. +const kaspaNode = require('../../../crypto_nodes/kaspa/kaspa_node.js') +const crypto = require('crypto') +const path = require('path') + +function kaspaPlatform() { + if (process.platform === 'linux' && process.arch === 'x64') return 'linux-amd64' + if (process.platform === 'win32') return 'win64' + if (process.platform === 'darwin') return 'osx' + throw new Error('no Kaspa release asset is recorded for ' + process.platform + '/' + process.arch + '') +} + +function downloadFile(url, filePath) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(filePath) + https.get(url, (res) => { + if (res.statusCode !== 200) { + file.close() + reject(new Error('download failed HTTP ' + res.statusCode + ': ' + url)) + return + } + res.pipe(file) + file.on('finish', () => file.close(() => resolve(filePath))) + }).on('error', (error) => { + file.close() + reject(error) + }) + }) +} + +async function getKaspaNode(version) { + if (version.startsWith("v")) version = version.substring(1) + const tag = "v" + version + if (tag !== kaspaNode.VERSION) { + throw new Error('crypto_nodes/kaspa records only ' + kaspaNode.VERSION + ', got ' + tag + + '(add the hash to crypto_nodes/kaspa/ASSET-HASHES.md first, then allow the version)') + } + + const platform = kaspaPlatform() + const expected = kaspaNode.assertHashKnown(platform) // unrecorded means it throws (fail-closed) + const url = kaspaNode.downloadUrl(platform) + + const destination = cryptoNodesDir + "/kaspa" + fs.mkdirSync(destination, { recursive: true }) + const zipPath = path.join(destination, path.basename(url)) + + // already placed with the right hash: skip the download, same intent as usePlacedBitcoinTarball + let placedOk = false + if (fs.existsSync(zipPath)) { + const actual = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex') + placedOk = kaspaNode.verifySha256(actual, expected) + if (!placedOk) logger.log("placed Kaspa archive does not match the registered sha256; re-downloading") + } + if (!placedOk) await downloadFile(url, zipPath) + + const actual = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex') + if (!kaspaNode.verifySha256(actual, expected)) { + throw new Error('Kaspa archive failed its sha256 check: expected ' + expected + ', got ' + actual) + } + + // unpacking: the release archive is flat (kaspad / kaspa-wallet / rothschild / stratum-bridge) + try { + require('child_process').execFileSync('unzip', ['-o', '-q', zipPath, '-d', destination]) + } catch (error) { + throw new Error('failed to unpack the Kaspa archive (system unzip required): ' + (error && error.message ? error.message : error)) + } + return true +} + module.exports = { nodeVersion, + getKaspaNode, resolveMirrorAddresses, isTransportFailure, pinnedLookup, diff --git a/src/services/version_service.js b/src/services/version_service.js index 16cc8599..329796bc 100644 --- a/src/services/version_service.js +++ b/src/services/version_service.js @@ -82,6 +82,12 @@ async function checkRemoteNodeVersion(coin) { case Coin.LITECOIN: githubProjectVersion = await gitHubDownloader.getLatestCompatibleVersion("litecoin-project", "litecoin", true) break + // Kaspa is not the bitcoind family: the repository and asset names differ. + // Only one platform hash has been computed for that repository so far (see ASSET-HASHES.md), + // so linux-amd64 is pending and the download side fails closed rather than skipping. + case Coin.KASPA: + githubProjectVersion = await gitHubDownloader.getLatestCompatibleVersion("kaspanet", "rusty-kaspa", true) + break } setRemoteModuleVersion(NODE_MODULE_NAME + SEP + coin, githubProjectVersion) diff --git a/test/unit/crypto_node_conf_placeholders.test.js b/test/unit/crypto_node_conf_placeholders.test.js index 9cc390ae..69b4ab78 100644 --- a/test/unit/crypto_node_conf_placeholders.test.js +++ b/test/unit/crypto_node_conf_placeholders.test.js @@ -8,6 +8,11 @@ const path = require('path') const cryptoNodesDir = path.join(__dirname, '../../crypto_nodes') +// Kaspa family: kaspad's local RPC has no rpcuser/rpcpassword (neither appears in args.rs), +// so its conf cannot carry placeholders. This test exists to forbid leaked credentials, and for +// Kaspa it asserts the opposite: no credential line may appear anywhere in the file. +const CREDENTIAL_FREE_COINS = ['kaspa'] + function listConfFiles() { const files = [] for (const coin of fs.readdirSync(cryptoNodesDir)) { @@ -34,6 +39,12 @@ describe('bundled crypto-node conf files', function () { const content = fs.readFileSync(file, 'utf8') const userLine = content.match(/^rpcuser=(.*)$/m) const passLine = content.match(/^rpcpassword=(.*)$/m) + // credential-free family (kaspad's local RPC takes no user/password): the assertion is reversed - no credential line may appear + if (CREDENTIAL_FREE_COINS.some((coin) => file.includes(path.sep + coin + path.sep))) { + assert.strictEqual(userLine, null, `${file}: no rpcuser line may appear for this family`) + assert.strictEqual(passLine, null, `${file}: no rpcpassword line may appear for this family`) + continue + } assert.ok(userLine, `${file}: missing rpcuser line (substitution anchor)`) assert.ok(passLine, `${file}: missing rpcpassword line (substitution anchor)`) assert.strictEqual(userLine[1], '__XCHAIN_NODE_RPC_USER__', diff --git a/test/unit/crypto_nodes/kaspa.test.js b/test/unit/crypto_nodes/kaspa.test.js new file mode 100644 index 00000000..d323af21 --- /dev/null +++ b/test/unit/crypto_nodes/kaspa.test.js @@ -0,0 +1,170 @@ +/********************************************************************* + * Unit tests for crypto_nodes/kaspa (mocha; xchain-node globs every .test.js under test/unit) + **********************************************************************/ +'use strict'; +const { expect } = require('chai'); +const k = require('../../../crypto_nodes/kaspa/kaspa_node.js'); + +describe('crypto_nodes/kaspa node module', function () { + + describe('release assets', function () { + it('every platform has an asset name, with a URL on the official release', function () { + for (const p of ['linux-amd64', 'win64', 'osx']) { + const a = k.assetFor(p); + expect(a.file).to.match(/^rusty-kaspa-v\d+\.\d+\.\d+-/); + expect(k.downloadUrl(p)).to.equal( + 'https://github.com/kaspanet/rusty-kaspa/releases/download/' + k.VERSION + '/' + a.file); + } + }); + + it('an unknown platform throws immediately (never returns undefined)', function () { + expect(() => k.assetFor('solaris')).to.throw(/unknown platform/); + }); + + it('the win64 sha256 is 64 hex characters (computed locally, then registered)', function () { + const h = k.assertHashKnown('win64'); + expect(h).to.have.length(64); + expect(h).to.match(/^[0-9a-f]{64}$/); + }); + + it('fail-closed: a platform with no registered hash is REFUSED for download', function () { + expect(() => k.assertHashKnown('linux-amd64')).to.throw(/refusing to download/); + }); + + it('verifySha256: case-insensitive, and anything not 64 long throws', function () { + const h = k.assertHashKnown('win64'); + expect(k.verifySha256(h.toUpperCase(), h)).to.equal(true); + expect(k.verifySha256('0'.repeat(64), h)).to.equal(false); + expect(() => k.verifySha256('ab', h)).to.throw(/64 hex/); + expect(() => k.verifySha256(h, '')).to.throw(/64 hex/); + }); + }); + + describe('port table (only the two sets verified in args.rs)', function () { + it('mainnet:16110 / 16111 / 17110 / 18110', function () { + expect(k.portsFor('mainnet')).to.deep.equal({ grpc: 16110, p2p: 16111, wrpcBorsh: 17110, wrpcJson: 18110 }); + }); + + it('testnet: 16210 / 16211 / 17210 / 18210 (both wRPC ports shifted by +1000)', function () { + expect(k.portsFor('testnet')).to.deep.equal({ grpc: 16210, p2p: 16211, wrpcBorsh: 17210, wrpcJson: 18210 }); + }); + + it('simnet defaults are UNVERIFIED, so ports must be passed explicitly', function () { + expect(() => k.portsFor('simnet')).to.throw(/unverified/); + expect(k.portsFor('simnet', { grpc: 16510, p2p: 16511, wrpcBorsh: 17510, wrpcJson: 18510 })) + .to.deep.equal({ grpc: 16510, p2p: 16511, wrpcBorsh: 17510, wrpcJson: 18510 }); + }); + }); + + describe('startup arguments', function () { + it('mainnet: no network flag, and appdir/utxoindex/listen/--yes/--disable-upnp', function () { + const a = k.buildRunArgs({ network: 'mainnet', appdir: '/data/kaspa' }); + expect(a).to.include('--appdir=/data/kaspa'); + expect(a).to.include('--utxoindex'); + expect(a).to.include('--rpclisten=127.0.0.1:16110'); + expect(a).to.include('--rpclisten-borsh=127.0.0.1:17110'); + expect(a).to.include('--rpclisten-json=127.0.0.1:18110'); + expect(a).to.include('--listen=127.0.0.1:16111'); + expect(a).to.include('--yes'); + expect(a).to.include('--disable-upnp'); + expect(a).to.not.include('--testnet'); + expect(a).to.not.include('--archival'); // archival stays off by default (disk) + }); + + it('testnet: adds --testnet and uses the 16210/17210/18210 set', function () { + const a = k.buildRunArgs({ network: 'testnet', appdir: '/d' }); + expect(a).to.include('--testnet'); + expect(a).to.include('--rpclisten=127.0.0.1:16210'); + expect(a).to.include('--rpclisten-borsh=127.0.0.1:17210'); + }); + + it('indexer mode: archival on (KIP-15 keeps only ~30 hours of history)', function () { + const a = k.buildRunArgs({ network: 'mainnet', appdir: '/d', archival: true }); + expect(a).to.include('--archival'); + }); + + it('simnet bootstrap: mining unsynced is allowed, ports must be explicit', function () { + const a = k.buildRunArgs({ + network: 'simnet', appdir: '/d', allowUnsyncedMining: true, + ports: { grpc: 16510, p2p: 16511, wrpcBorsh: 17510, wrpcJson: 18510 } + }); + expect(a).to.include('--simnet'); + expect(a).to.include('--enable-unsynced-mining'); + expect(a).to.include('--rpclisten-borsh=127.0.0.1:17510'); + expect(() => k.buildRunArgs({ network: 'simnet', appdir: '/d' })).to.throw(/unverified/); + }); + + it('argument checks: a missing appdir or a bad network name both throw', function () { + expect(() => k.buildRunArgs({ network: 'mainnet' })).to.throw(/appdir/); + expect(() => k.buildRunArgs({ network: 'mainet', appdir: '/d' })).to.throw(/unknown network/); + }); + }); + + describe('what replaces -cli', function () { + it('tip guard: wRPC-JSON getBlockDagInfo instead, with the tip taken from sink', function () { + const p = k.tipGuardPlan({ wrpcJsonPort: 18110, network: 'mainnet' }); + expect(p.url).to.equal('ws://127.0.0.1:18110/json'); + expect(p.method).to.equal('getBlockDagInfo'); + expect(p.pick).to.include('sink'); + expect(() => k.tipGuardPlan({})).to.throw(/wrpcJsonPort/); + }); + + it('version probe: getServerInfo serverVersion instead', function () { + const p = k.remoteVersionProbe({ wrpcJsonPort: 16210 }); + expect(p.method).to.equal('getServerInfo'); + expect(p.pick).to.deep.equal(['serverVersion']); + }); + + it('IBD: progress is the judge (isSynced alone is not trustworthy - idle nodes measured false)', function () { + const j = k.judgeInitialBlockDownload; + // still growing: still catching up + expect(j({ first: { headerCount: 100, blockCount: 100 }, second: { headerCount: 900, blockCount: 900 } }).initialblockdownload) + .to.equal(true); + // unchanged: caught up (even with isSynced=false - the exact case that was measured wrong) + expect(j({ first: { headerCount: 1504, blockCount: 1504 }, second: { headerCount: 1504, blockCount: 1504 }, isSynced: false }).initialblockdownload) + .to.equal(false); + // too few samples: wait conservatively (do not let it through) + expect(j({ first: null, second: { headerCount: 1, blockCount: 1 } }).initialblockdownload).to.equal(true); + expect(j({}).initialblockdownload).to.equal(true); + // contradictory numbers: wait conservatively + expect(j({ first: { headerCount: 5, blockCount: 9 }, second: { headerCount: 5, blockCount: 8 } }).initialblockdownload) + .to.equal(true); + // with isSynced=true as a backstop, treat it as caught up + expect(j({ first: { headerCount: 5, blockCount: 9 }, second: { headerCount: 5, blockCount: 8 }, isSynced: true }).initialblockdownload) + .to.equal(false); + }); + + it('health start period: first sync is hours (RPC is unusable throughout IBD)', function () { + expect(k.healthStartPeriodSeconds({ network: 'mainnet', firstSync: true })).to.equal(6 * 3600); + expect(k.healthStartPeriodSeconds({ network: 'mainnet', archival: true, firstSync: true })).to.equal(12 * 3600); + expect(k.healthStartPeriodSeconds({ network: 'simnet', firstSync: true })).to.equal(120); + expect(k.healthStartPeriodSeconds({ network: 'mainnet', firstSync: false })).to.equal(600); + }); + }); + + describe('liveness', function () { + it('the probe checks both the wRPC port and log freshness (a port alone misses a wedge)', function () { + const p = k.buildHealthProbe({ wrpcBorshPort: 17110, logDir: '/data/kaspa/logs', freshMinutes: 5 }); + expect(p[0]).to.equal('sh'); + expect(p[1]).to.equal('-c'); + expect(p[2]).to.include('nc -z 127.0.0.1 17110'); + expect(p[2]).to.include('find /data/kaspa/logs -type f -mmin -5'); + }); + + it('a missing port throws', function () { + expect(() => k.buildHealthProbe({})).to.throw(/wrpcBorshPort/); + }); + }); +}); + +describe('crypto_nodes/kaspa: the Dockerfile agrees with the module', function () { + it('start-period equals healthStartPeriodSeconds(mainnet, archival, firstSync)', function () { + const fs = require('fs'); + const path = require('path'); + const dockerfile = fs.readFileSync(path.join(__dirname, '../../../crypto_nodes/kaspa/Dockerfile'), 'utf8'); + const m = dockerfile.match(/--start-period=(\d+)s/); + expect(m, 'Dockerfile must declare a start-period').to.not.equal(null); + const expected = k.healthStartPeriodSeconds({ network: 'mainnet', archival: true, firstSync: true }); + expect(Number(m[1])).to.equal(expected); // if either magic number drifts, this goes red + }); +});