Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions crypto_nodes/kaspa/ASSET-HASHES.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions crypto_nodes/kaspa/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 <coin>-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"]
31 changes: 31 additions & 0 deletions crypto_nodes/kaspa/kaspa-mainnet.conf
Original file line number Diff line number Diff line change
@@ -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"
27 changes: 27 additions & 0 deletions crypto_nodes/kaspa/kaspa-simnet.conf
Original file line number Diff line number Diff line change
@@ -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"
25 changes: 25 additions & 0 deletions crypto_nodes/kaspa/kaspa-testnet.conf
Original file line number Diff line number Diff line change
@@ -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"
190 changes: 190 additions & 0 deletions crypto_nodes/kaspa/kaspa_node.js
Original file line number Diff line number Diff line change
@@ -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, <coin>-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 <coin>-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 <coin>-cli changes the shape (every other coin has it) --
// The platform's bootstrap_node_tip_guard takes the tip from `<coin>-cli -conf=... getblockcount`;
// kaspad has no such CLI, so this uses wRPC-JSON instead.
// Measured: ws://127.0.0.1:<wrpcJson>/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 `<coin>-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 `<coin>-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
};
Loading