From bc8b546d2dba993ffe06a829be2de338cd3a5595 Mon Sep 17 00:00:00 2001 From: midnest Date: Wed, 14 Oct 2026 14:23:53 +0800 Subject: [PATCH] xchain-encoder: Kaspa support Adds Kaspa to xchain-encoder: 3 new file(s) and 3 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. --- src/build/crypto_networks.js | 11 +- src/build/kaspa_tx_builder.js | 269 +++++++++++++++++++ src/coins/KAS.js | 272 +++++++++++++++++++ src/coins/consensus_pin.js | 2 + src/coins/index.js | 1 + test/unit/build/kaspa_tx_builder.test.js | 322 +++++++++++++++++++++++ 6 files changed, 875 insertions(+), 2 deletions(-) create mode 100644 src/build/kaspa_tx_builder.js create mode 100644 src/coins/KAS.js create mode 100644 test/unit/build/kaspa_tx_builder.test.js diff --git a/src/build/crypto_networks.js b/src/build/crypto_networks.js index bc8671e..c6b9459 100644 --- a/src/build/crypto_networks.js +++ b/src/build/crypto_networks.js @@ -26,7 +26,8 @@ const coins = require('../coins'); const { ENVELOPE_RECOGNITION_ACTIVATION } = require('../protocol/changes'); const { ParamTypeError } = require('./errors'); -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 @@ -46,7 +47,13 @@ class CryptoNetworks { static getBitcoinJsNetwork(networkName){ const p = parseNetworkName(networkName); if(!p) throw new ParamTypeError(`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 + // address-parsing error which is very hard to trace back to its cause. + if(!cfg.net) + throw new ParamTypeError(`Network "${networkName}" has no bitcoinjs network object: coin "${p.tick}" declares wireFormat "${cfg.wireFormat}". Consumers must branch on the wireFormat family.`); + return cfg.net; } // Indexing start height, and a CONSENSUS input: coins/index.js folds firstBlock into diff --git a/src/build/kaspa_tx_builder.js b/src/build/kaspa_tx_builder.js new file mode 100644 index 0000000..992766d --- /dev/null +++ b/src/build/kaspa_tx_builder.js @@ -0,0 +1,269 @@ +/********************************************************************* + * + * 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 transaction builder — the family counterpart of the PSBT path. + * + * This is the Kaspa implementation of what adapters/chain_adapter.js declares + * as buildUnsignedTx(). The action string is chain-agnostic (createAction + * produces it), so the family only has to carry it in the transaction's + * `payload` field, sign, and broadcast. + * + * The wasm SDK is INJECTED, never required here: this repo's dependency set + * does not include @kluster/kaspa-wasm, and a builder that reaches for a + * hardcoded client cannot be driven by the tests or by a different node + * connector. Pass { sdk } (or let the caller pass its own signer). + * + * Measured constraints, each of which cost a live debugging round: + * + * · Fee rule: minimum relay fee = mass x 100 sompi/gram + * (rusty-kaspa wallet/core/src/tx/mass.rs: + * MINIMUM_RELAY_TRANSACTION_FEE = 100_000 sompi/kg, i.e. 100 sompi/g; + * calc_minimum_required_transaction_relay_fee = mass*100000/1000). + * P0 measurements: 52 B payload -> fee mass 10,021; 8,192 B -> 18,420. + * + * · The SDK's generator path reports a feeAmount that is TOO LOW to be + * relayed: measured 214,000 sompi on a transaction whose mass was 10,021, + * which needs 1,002,100. So the fee is computed here from mass and used to + * drive priorityFee, in two passes (build with 0, read mass, rebuild). + * + * · payload is capped at MAX_ACTION_DATA_LENGTH, the same ceiling the + * protocol layer enforces (xchain-decoder/src/protocol/constants.js:40). + * An action string must also be printable ASCII, as on every other chain. + ********************************************************************/ +'use strict'; + +const crypto = require('crypto'); + +// -- the obfuscation layer (whitepaper 4.2) ---------------------------------- +// "Before embedding, the protocol prepends a 4-byte magic prefix XCHN to the ACTION +// string and applies AES-128-CTR. The key material derives from the spending +// transaction's first input txid: the txid is reversed from little-endian to +// big-endian, then the leading hex forms the key and the following hex forms the IV." +// +// It is obfuscation, not encryption -- the key is public chain data. Its stated purpose is +// to stop naive keyword scanning of the chain and to keep unrelated data from being misread +// as an action. Skipping it (as the Kaspa family first did) leaves cleartext actions on +// chain, and makes us unreadable to any other XChain implementation. +const MAGIC_PREFIX = Buffer.from('XCHN', 'utf8'); + +function cipherKeyIvFromTxid(txidHex) { + const bytes = Buffer.from(String(txidHex || '').replace(/^0x/, ''), 'hex'); + if (bytes.length !== 32) { + throw new Error('obfuscation needs the 32-byte first-input txid, got ' + bytes.length + ' bytes'); + } + const bigEndian = Buffer.from(bytes).reverse(); // 4.2: LE -> BE + return { key: bigEndian.slice(0, 16), iv: bigEndian.slice(16, 32) }; +} + +function obfuscateAction(action, firstInputTxid) { + const { key, iv } = cipherKeyIvFromTxid(firstInputTxid); + const cipher = crypto.createCipheriv('aes-128-ctr', key, iv); + const plain = Buffer.concat([MAGIC_PREFIX, Buffer.from(String(action), 'utf8')]); + return Buffer.concat([cipher.update(plain), cipher.final()]); +} + +// Inverse, for readers. null means the magic prefix was absent (the payload was not +// written by an obfuscating writer), so callers can decide whether to accept it. +function deobfuscateAction(payload, firstInputTxid) { + const bytes = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload || ''), 'hex'); + if (!bytes.length) return null; + try { + const { key, iv } = cipherKeyIvFromTxid(firstInputTxid); + const decipher = crypto.createDecipheriv('aes-128-ctr', key, iv); + const plain = Buffer.concat([decipher.update(bytes), decipher.final()]); + if (!plain.slice(0, 4).equals(MAGIC_PREFIX)) return null; + return plain.slice(4).toString('utf8'); + } catch (error) { + return null; + } +} +const MAX_ACTION_DATA_LENGTH = 8192; + +// ── fee rule (pure) ──────────────────────────────────────────────────────── +const SOMPI_PER_GRAM = 100n; // MINIMUM_RELAY_TRANSACTION_FEE = 100_000 sompi/kg + +function minimumFeeSompi(mass) { + const grams = BigInt(mass); + if (grams <= 0n) throw new Error('mass must be positive'); + return grams * SOMPI_PER_GRAM; +} + +// The node's mass estimate can differ from ours by a few grams, so a relayable +// fee carries a margin (default 10%) plus a fixed floor. +function safeFeeSompi(mass, { marginBps = 1000, floor = 1000n } = {}) { + const minimum = minimumFeeSompi(mass); + return minimum + (minimum * BigInt(marginBps)) / 10000n + floor; +} + +// Throws rather than broadcasting a transaction the mempool will reject. +function assertFeeSufficient(mass, paidSompi) { + const needed = minimumFeeSompi(mass); + const paid = BigInt(paidSompi); + if (paid < needed) { + throw new Error('fee insufficient: mass=' + mass + ' needs ' + needed + ' sompi, paid ' + paid); + } + return true; +} + +// ── action string (pure) ─────────────────────────────────────────────────── +function assertActionString(action) { + if (typeof action !== 'string' || action.length === 0) throw new Error('action string is empty'); + if (!action.includes('|')) throw new Error('action string has no | separator'); + const bytes = Buffer.byteLength(action, 'utf8'); + // 4.3 caps the ASSEMBLED payload, not the raw string: the compiled payload is the + // action plus the 4-byte magic, so an action of exactly the ceiling would overflow it. + if (bytes + MAGIC_PREFIX.length > MAX_ACTION_DATA_LENGTH) { + throw new Error('action string too long: ' + bytes + ' bytes + ' + MAGIC_PREFIX.length + ' magic > ' + MAX_ACTION_DATA_LENGTH); + } + if (!/^[\x20-\x7e]+$/.test(action)) throw new Error('action string must be printable ASCII (same as the other chains)'); + return { action, bytes }; +} + +function buildReceipt({ action, txid, mass, fee, network, extra }) { + return Object.assign({ + ts: new Date().toISOString(), + chain: 'KASPA', + network, + action, + txid: String(txid), + mass: String(mass), + fee: String(fee), + }, extra || {}); +} + +// ── build + sign (two passes, fee driven by mass) ────────────────────────── +// entries MUST come from the node (rpc.getUtxosByAddresses). Entries rebuilt +// from REST JSON cannot be signed: the shape is not the one the signer expects. +async function buildAndSign({ + sdk, entries, address, action, privateKey, networkId = 'testnet-10', + spendKeepSompi = 100000000n, // leave change rather than a dust output +}) { + if (!sdk || typeof sdk.createTransactions !== 'function') throw new Error('buildAndSign needs the wasm sdk (createTransactions)'); + if (!entries || !entries.length) throw new Error('buildAndSign needs at least one UTXO entry'); + const { bytes } = assertActionString(action); + + // 4.2: the payload carries the OBFUSCATED action, keyed on the first input's txid. + const firstInputTxid = entries[0].outpoint && entries[0].outpoint.transactionId; + const payload = obfuscateAction(action, firstInputTxid); + const total = entries.reduce((sum, e) => sum + BigInt(e.amount), 0n); + // The SDK takes the output amount as a JS number, so a total beyond 2^53-1 sompi + // cannot be represented exactly. Refuse instead of rounding a coin amount. + const MAX_EXACT_SOMPI = 9007199254740991n; + if (total > MAX_EXACT_SOMPI) { + throw new Error('buildAndSign: entry total ' + total + ' sompi exceeds the exact-integer range of the SDK; split the inputs'); + } + const outputAmount = Number(total - BigInt(spendKeepSompi)); + if (!(outputAmount > 0)) throw new Error('entries do not cover the spend: ' + total + ' sompi'); + + // The wasm SDK throws BARE STRINGS for its own limits ("Storage mass exceeds maximum", measured + // on testnet-10 with an 8000-byte payload over 2 UTXOs). Our callers log error.message, so that + // refusal arrived as the word "undefined" - a money-class failure delivered as nothing at all. + const SDK_ATTEMPTS = 3; + const callSdk = async (options) => { + let lastError = null; + for (let attempt = 1; attempt <= SDK_ATTEMPTS; attempt += 1) { + try { + return await sdk.createTransactions(options); + } catch (error) { + lastError = error; + const text = error && error.message ? error.message : String(error); + // Measured on testnet-10: the SAME valid action over the SAME UTXOs was refused once + // and then built fine minutes later ("Storage mass exceeds maximum"), while a + // malformed action was refused ten times out of ten. So this one message can be + // either transient or a real refusal, and a bounded retry is the only way to tell + // them apart - a bad action still fails on the last attempt, with the same message. + // Safety: this runs before signing or broadcasting anything, so a retry cannot + // double-spend or double-publish. + if (!/storage mass|mass exceeds/i.test(text) || attempt === SDK_ATTEMPTS) { + const parts = String(action).split('|'); + const shape = parts[0] + ' v' + parts[1] + ', ' + parts.length + ' field(s)'; + throw new Error('buildAndSign: ' + text + ' [action ' + shape + ', attempt ' + attempt + + '/' + SDK_ATTEMPTS + ' - if the SDK text names a weight or mass limit, check the ' + + 'action against the documented wire format for that action and version first]'); + } + } + } + throw lastError; + }; + + const buildOptions = (priorityFee) => ({ + entries, + outputs: [{ address, amount: outputAmount }], + changeAddress: address, + priorityFee, + networkId, + payload, + }); + + // Pass 1: get the mass the node will charge for. Pass 2: rebuild with a fee + // derived from it. Never trust the SDK's feeAmount (see the header). + let built = await callSdk(buildOptions(0n)); + const massProbe = BigInt((built.transactions[0] && built.transactions[0].mass) || 0n); + built = await callSdk(buildOptions(massProbe > 0n ? safeFeeSompi(massProbe) : 5000000n)); + + const pending = built.transactions[0]; + const mass = BigInt(pending.mass || 0n) || massProbe || 1n; + await pending.sign([privateKey]); + + const transaction = pending.transaction; + const inSum = entries.reduce((sum, e) => sum + BigInt(e.amount), 0n); + const outSum = transaction.outputs.reduce((sum, o) => sum + BigInt(o.value), 0n); + const paidFee = inSum - outSum; + // Self-check against what was actually embedded, not against what was asked + // for: a fee that silently ends up below the relay minimum is the exact + // failure this module exists to prevent. + assertFeeSufficient(mass, paidFee); + // M1/M2, found by the money review: the check above is a LOWER bound only, so a build whose + // change never came back passed it and was returned as a receipt. Two things are asserted now, + // both derived from the signed transaction: the fee must not be fat, and when a change is + // expected there must be an output to carry it. WHICH address that output pays is the caller's + // contract - this module cannot see the recipient through the SDK's output shape, and saying so + // is the honest boundary rather than implying a guarantee it cannot make. + // Refusing here, after sign(), is deliberate: the caller has a transaction object it must not + // broadcast, and losing the build is the cheap outcome next to a lost coin. + const fairFee = safeFeeSompi(mass); + if (paidFee > fairFee * 10n) { + throw new Error('buildAndSign: effective fee ' + paidFee + ' sompi is far above the mass-derived ' + + fairFee + ' sompi; the change did not come back to the sender (refusing to sign)'); + } + const expectedChange = inSum - BigInt(outputAmount) - fairFee; + if (expectedChange > 100000n && transaction.outputs.length < 2) { + throw new Error('buildAndSign: about ' + expectedChange + ' sompi of change was expected but the ' + + 'transaction carries a single output; the remainder would become fee (refusing to sign)'); + } + + return { transaction, txid: String(transaction.id), mass, paidFee, payloadBytes: bytes, payloadHex: Buffer.from(payload).toString('hex') }; +} + +// ── read-back verification (payload must round-trip byte for byte) ───────── +function payloadHexOf(wirePayload) { + if (wirePayload === null || wirePayload === undefined) return null; + if (typeof wirePayload === 'string') return wirePayload.replace(/^0x/, '').toLowerCase(); + return Buffer.from(wirePayload).toString('hex'); +} + +module.exports = { + MAX_ACTION_DATA_LENGTH, + SOMPI_PER_GRAM, + minimumFeeSompi, + safeFeeSompi, + assertFeeSufficient, + assertActionString, + buildReceipt, + buildAndSign, + payloadHexOf, + obfuscateAction, + deobfuscateAction, + cipherKeyIvFromTxid, +}; diff --git a/src/coins/KAS.js b/src/coins/KAS.js new file mode 100644 index 0000000..3d166f7 --- /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 5e0717c..2b260a1 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 f4f6a36..5606751 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/test/unit/build/kaspa_tx_builder.test.js b/test/unit/build/kaspa_tx_builder.test.js new file mode 100644 index 0000000..1b854a1 --- /dev/null +++ b/test/unit/build/kaspa_tx_builder.test.js @@ -0,0 +1,322 @@ +/********************************************************************* + * + * 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 transaction builder: the fee rule and the two-pass build. + * + * The wasm SDK is faked here on purpose. What has to be pinned is not the SDK + * but the two things that were wrong before this module existed: the fee has to + * come from mass (the SDK's own feeAmount was measured below the relay minimum), + * and a build that ends up under-fee'd must throw instead of broadcasting. + **********************************************************************/ +'use strict'; + +const assert = require('assert'); + +const builder = require('../../../src/build/kaspa_tx_builder.js'); +const { buildAndSign } = require('../../../src/build/kaspa_tx_builder.js'); + +// Module scope on purpose: the obfuscation key comes from the first input's txid (4.2), so +// every signable entry set in this file must carry the outpoint it spends. A set without one +// fails closed at build time -- silently writing a cleartext payload would be worse. +const ENTRIES = [{ amount: '5000000000', outpoint: { transactionId: 'aa'.repeat(32), index: 0 } }]; + +// A fake wasm SDK that records the priorityFee it was handed and honours it. +function fakeSdk({ mass = 10021n, honourFee = true } = {}) { + const calls = []; + return { + calls, + async createTransactions(options) { + calls.push({ priorityFee: String(options.priorityFee), payloadBytes: options.payload.length }); + const priorityFee = BigInt(options.priorityFee); + // A realistic shape: the action output AND the change output, so inSum - outSum is + // exactly the fee that was asked for. The old fake folded both into a single output, + // which made 90x the input look like a fee - the new money guard refuses that, and a + // fake that a real SDK would never produce has no business standing in for one. + const inSum = options.entries.reduce((s, e) => s + BigInt(e.amount), 0n); + const action = BigInt(options.outputs[0].amount); + const fee = honourFee ? priorityFee : 0n; + const change = inSum - action - fee; + const outputs = change > 0n ? [{ value: action }, { value: change }] : [{ value: inSum - fee }]; + return { + transactions: [{ + mass: String(mass), + async sign() {}, + transaction: { + id: 'cc'.repeat(32), + outputs, + }, + }], + }; + }, + }; +} + +describe('kaspa_tx_builder: fee rule', function () { + + it('minimum relay fee is mass x 100 sompi/gram', function () { + assert.strictEqual(builder.SOMPI_PER_GRAM, 100n); + // Both numbers are P0 measurements, not round figures. + assert.strictEqual(builder.minimumFeeSompi(10021), 1002100n); + assert.strictEqual(builder.minimumFeeSompi(18420), 1842000n); + assert.strictEqual(builder.minimumFeeSompi('10021'), 1002100n); + assert.throws(() => builder.minimumFeeSompi(0), /must be positive/); + assert.throws(() => builder.minimumFeeSompi(-5), /must be positive/); + }); + + it('the relayable fee carries a margin over the minimum', function () { + const min = builder.minimumFeeSompi(10021); + const safe = builder.safeFeeSompi(10021); + assert.ok(safe > min, 'margin must raise the fee'); + assert.strictEqual(safe, min + (min * 1000n) / 10000n + 1000n); + }); + + it('assertFeeSufficient throws below the minimum instead of broadcasting', function () { + assert.strictEqual(builder.assertFeeSufficient(10021, 1002100n), true); + assert.throws(() => builder.assertFeeSufficient(10021, 214000n), /fee insufficient/); + }); +}); + +describe('kaspa_tx_builder: action string', function () { + + it('accepts a normal action and reports its byte length', function () { + const out = builder.assertActionString('ORDER|0|DOGE||1000'); + assert.strictEqual(out.action, 'ORDER|0|DOGE||1000'); + assert.strictEqual(out.bytes, Buffer.byteLength('ORDER|0|DOGE||1000', 'utf8')); + }); + + it('rejects empty, separator-less, oversize and non-ASCII payloads', function () { + assert.throws(() => builder.assertActionString(''), /empty/); + assert.throws(() => builder.assertActionString('ORDER'), /separator/); + assert.throws(() => builder.assertActionString('A|' + 'x'.repeat(8192)), /too long/); + assert.throws(() => builder.assertActionString('ORDER|0|ünïcode'), /printable ASCII/); + }); + + it('accepts the largest action that still fits once obfuscated', function () { + // 4.3 caps the ASSEMBLED payload: raw bytes + the 4-byte magic must fit. + const MAGIC = 4; + const action = 'A|' + 'x'.repeat(builder.MAX_ACTION_DATA_LENGTH - 2 - MAGIC); + assert.strictEqual(Buffer.byteLength(action, 'utf8'), builder.MAX_ACTION_DATA_LENGTH - MAGIC); + assert.doesNotThrow(() => builder.assertActionString(action)); + const tooBig = 'A|' + 'x'.repeat(builder.MAX_ACTION_DATA_LENGTH - 1 - MAGIC); + assert.throws(() => builder.assertActionString(tooBig), /too long/, 'one byte past the assembled ceiling must be refused'); + }); +}); + +describe('kaspa_tx_builder: two-pass build', function () { + + // The obfuscation key comes from the first input's txid (4.2), so a signable entry set + + it('pass 1 asks for zero fee, pass 2 asks for the mass-derived fee', async function () { + const sdk = fakeSdk({ mass: 10021n }); + const result = await builder.buildAndSign({ + sdk, entries: ENTRIES, address: 'kaspatest:x', action: 'ORDER|0|DOGE', privateKey: {}, + }); + + assert.strictEqual(sdk.calls.length, 2, 'exactly two passes'); + assert.strictEqual(sdk.calls[0].priorityFee, '0'); + assert.strictEqual(sdk.calls[1].priorityFee, String(builder.safeFeeSompi(10021n))); + assert.strictEqual(result.mass, 10021n); + assert.ok(result.paidFee >= builder.minimumFeeSompi(result.mass)); + // 4.2: the on-chain payload is the OBFUSCATED action (magic + AES-128-CTR), so the + // assertion is about the wire shape, not about cleartext equality. + const actionBytes = Buffer.byteLength('ORDER|0|DOGE', 'utf8'); + assert.strictEqual(result.payloadHex.length / 2, actionBytes + 4, 'exactly the 4-byte magic wider'); + assert.notStrictEqual(result.payloadHex, Buffer.from('ORDER|0|DOGE', 'utf8').toString('hex'), 'must not be cleartext'); + assert.strictEqual(builder.deobfuscateAction(result.payloadHex, ENTRIES[0].outpoint.transactionId), 'ORDER|0|DOGE'); + assert.strictEqual(result.txid, 'cc'.repeat(32)); + }); + + it('THROWS when the built transaction ends up under-fee\'d (the failure this exists to prevent)', async function () { + // honourFee: false models the SDK dropping the priorityFee — exactly how the + // too-low 214,000 sompi build was produced before this module existed. + // spendKeepSompi is set small on purpose: with a 1 KAS change the shortfall + // would still clear the relay minimum, and the guard would (correctly) not fire. + const sdk = fakeSdk({ honourFee: false }); + await assert.rejects(() => builder.buildAndSign({ + sdk, entries: ENTRIES, address: 'kaspatest:x', action: 'ORDER|0|DOGE', privateKey: {}, + spendKeepSompi: 100000n, + }), /fee insufficient/); + }); + + it('refuses to build without entries or without the sdk', async function () { + await assert.rejects(() => builder.buildAndSign({ sdk: fakeSdk(), entries: [], address: 'a', action: 'A|1', privateKey: {} }), + /at least one UTXO entry/); + await assert.rejects(() => builder.buildAndSign({ entries: ENTRIES, address: 'a', action: 'A|1', privateKey: {} }), + /needs the wasm sdk/); + }); + + it('refuses when the entries cannot cover the spend', async function () { + await assert.rejects(() => builder.buildAndSign({ + sdk: fakeSdk(), entries: [{ amount: '5', outpoint: { transactionId: 'aa'.repeat(32), index: 0 } }], address: 'a', action: 'A|1', privateKey: {}, + spendKeepSompi: 100000000n, + }), /do not cover the spend/); + }); +}); + +describe('kaspa_tx_builder: read-back helper', function () { + + it('normalises the payload however the node hands it back', function () { + assert.strictEqual(builder.payloadHexOf('0xABcd'), 'abcd'); + assert.strictEqual(builder.payloadHexOf(Buffer.from([0xab, 0xcd])), 'abcd'); + assert.strictEqual(builder.payloadHexOf(null), null); + assert.strictEqual(builder.payloadHexOf(undefined), null); + }); +}); + +describe('kaspa_tx_builder: exact-integer guard', function () { + it('refuses an entry total beyond 2^53-1 sompi instead of rounding it', async function () { + const huge = [{ amount: '9007199254740992', outpoint: { transactionId: 'aa'.repeat(32), index: 0 } }]; // 2^53, one past the exact range + await assert.rejects(() => builder.buildAndSign({ + sdk: { async createTransactions() { throw new Error('sdk must not be called'); } }, + entries: huge, address: 'kaspatest:x', action: 'ORDER|0|DOGE', privateKey: {}, + spendKeepSompi: 1n, + }), /exceeds the exact-integer range/); + }); +}); + +describe('kaspa_tx_builder: 4.2 obfuscation', function () { + const TXID = 'aa'.repeat(32); + it('encrypts the action, so the chain carries no cleartext keywords', function () { + const enc = builder.obfuscateAction('ORDER|0|DOGE||1000000', TXID); + assert.strictEqual(enc.length, Buffer.byteLength('ORDER|0|DOGE||1000000', 'utf8') + 4, 'AES-CTR is a stream cipher: exactly the 4-byte magic wider'); + assert.ok(!enc.toString('utf8').includes('ORDER'), 'naive keyword scanning must fail'); + }); + it('round-trips, and refuses a payload it did not write', function () { + const enc = builder.obfuscateAction('ORDER|0|DOGE||1000000', TXID); + assert.strictEqual(builder.deobfuscateAction(enc, TXID), 'ORDER|0|DOGE||1000000'); + assert.strictEqual(builder.deobfuscateAction(enc, 'bb'.repeat(32)), null, 'wrong key must not yield text'); + assert.strictEqual(builder.deobfuscateAction(Buffer.from('ORDER|0|X'), TXID), null, 'cleartext has no magic'); + }); + it('derives key and IV from the reversed first-input txid (4.2)', function () { + const { key, iv } = builder.cipherKeyIvFromTxid(TXID); + assert.strictEqual(key.length, 16); + assert.strictEqual(iv.length, 16); + assert.strictEqual(key.toString('hex'), 'aa'.repeat(16)); + assert.throws(() => builder.cipherKeyIvFromTxid('00'), /32-byte first-input txid/); + }); +}); + +describe('kaspa_tx_builder: money pins (M1/M2), one guard per case', function () { + // M1 (fee upper bound) and M2 (a change output must exist) used to overlap: a swallowed + // change tripped both, so neither could be shown to be load-bearing on its own. Each case + // below trips exactly one of them, which is what makes the seal meaningful. + const COIN = 100000000n; + const entry = (amount) => ({ outpoint: { transactionId: 'ab'.repeat(32), index: 0 }, amount, address: 'kaspatest:qpz' }); + const build = (sdk, spendKeepSompi) => buildAndSign({ sdk, entries: [entry(10n * COIN)], + address: 'kaspatest:qpz', action: 'ORDER|0|DOGE||1', privateKey: {}, networkId: 'simnet', spendKeepSompi }); + + // Emits the action output and a change output, but shortens the change by 5 KAS: the shape + // rule is satisfied, so only the fee bound can object. + const sdkThatShrinksChange = (mass) => ({ + async createTransactions(options) { + const inSum = options.entries.reduce((s, e) => s + BigInt(e.amount), 0n); + const action = BigInt(options.outputs[0].amount); + const fee = BigInt(options.priorityFee || 0n); + const change = inSum - action - fee - 5n * COIN; + return { transactions: [{ mass, sign: async () => {}, transaction: { id: 'cd'.repeat(32), mass, + outputs: [{ value: action }, { value: change }] } }] }; + }, + }); + // Emits a single output, and swallows a keep small enough to stay under the fee bound. + const sdkThatDropsChange = (mass) => ({ + async createTransactions(options) { + const action = BigInt(options.outputs[0].amount); + return { transactions: [{ mass, sign: async () => {}, transaction: { id: 'ef'.repeat(32), mass, + outputs: [{ value: action }] } }] }; + }, + }); + it('surfaces a bare-string refusal from the wasm SDK instead of "undefined" (measured on testnet-10)', async function () { + // The real shape, measured with an 8000-byte payload over 2 UTXOs: the SDK throws a bare + // string, our call sites read error.message, and the operator saw the word "undefined". + const sdk = { async createTransactions() { throw 'Storage mass exceeds maximum'; } }; + await assert.rejects( + () => buildAndSign({ sdk, entries: [entry(10n * COIN)], address: 'kaspatest:qpz', + action: 'SEND|0|XCHAIN|1', privateKey: {}, networkId: 'simnet' }), + /Storage mass exceeds maximum[\s\S]*action SEND v0, 4 field\(s\)/, + 'a money-class refusal must reach the operator as a message, never as "undefined", ' + + 'and it must carry the action shape so a wire-format mistake is the visible suspect'); + }); + const honestSdk = (mass) => ({ + async createTransactions(options) { + const inSum = options.entries.reduce((s, e) => s + BigInt(e.amount), 0n); + const action = BigInt(options.outputs[0].amount); + const fee = BigInt(options.priorityFee || 0n); + const change = inSum - action - fee; + const outputs = [{ value: action }]; + if (change > 0n) outputs.push({ value: change }); + return { transactions: [{ mass, sign: async () => {}, transaction: { id: 'ff'.repeat(32), mass, outputs } }] }; + }, + }); + + it('M1 only: a shrunken change output is still a fat fee, so it is refused', async function () { + await assert.rejects(() => build(sdkThatShrinksChange(5000n)), /far above the mass-derived/, + 'two outputs is not enough: the fee bound has to see it too'); + }); + + it('M2 only: a single output with a small keep is refused for the missing change output', async function () { + await assert.rejects(() => build(sdkThatDropsChange(5000n), 5000000n), /single output/, + 'the shape rule has to fire on its own, not as a side effect of the fee bound'); + }); + + it('the honest SDK passes both guards and reports the fee it actually paid', async function () { + const out = await build(honestSdk(5000n)); + assert.ok(out.paidFee >= 0n); + assert.ok(out.paidFee <= 10n * COIN, 'not a fat fee: ' + out.paidFee); + }); +}); + +describe('kaspa_tx_builder: the bounded retry on the measured transient refusal', function () { + const COIN2 = 100000000n; + const oneEntry = () => [{ outpoint: { transactionId: 'ab'.repeat(32), index: 0 }, amount: 10n * COIN2, + address: 'kaspatest:qpz', utxoEntry: { amount: 10n * COIN2, + scriptPublicKey: { version: 0, scriptPublicKey: '65ab' }, blockDaaScore: 1n, isCoinbase: false } }]; + const honestBuild = async (options) => { + // The shape a real SDK returns: the action output AND the change output, so the effective + // fee equals what was asked for. A single inflated output would (correctly) trip the fee + // upper bound instead of exercising the retry. + const inSum = options.entries.reduce((sum, e) => sum + BigInt(e.amount !== undefined ? e.amount : e.utxoEntry.amount), 0n); + const action = BigInt(options.outputs[0].amount); + const fee = BigInt(options.priorityFee || 0n); + const change = inSum - action - fee; + const outputs = change > 0n ? [{ value: action }, { value: change }] : [{ value: inSum - fee }]; + return { transactions: [{ mass: 10021n, sign: async () => {}, + transaction: { id: 'cc'.repeat(32), outputs } }] }; + }; + + it('retries a transient mass refusal and succeeds - measured: the same valid action was refused once, then built', async function () { + let calls = 0; + const sdk = { async createTransactions(options) { + calls += 1; + if (calls < 3) throw 'Storage mass exceeds maximum'; + return honestBuild(options); + } }; + const out = await buildAndSign({ sdk, entries: oneEntry(), address: 'kaspatest:qpz', + action: 'BROADCAST|0|hello world|42', privateKey: {}, networkId: 'simnet' }); + assert.ok(calls > 1, 'it must have retried: ' + calls + ' attempt(s)'); + assert.ok(calls <= 4, + 'and stay bounded - two passes, at most three attempts each, so at most four calls: ' + calls); + assert.ok(out.mass, 'and a real build comes back'); + }); + + it('is bounded: a permanent refusal fails on the third attempt and never loops', async function () { + let calls = 0; + const sdk = { async createTransactions() { calls += 1; throw 'Storage mass exceeds maximum'; } }; + await assert.rejects( + () => buildAndSign({ sdk, entries: oneEntry(), address: 'kaspatest:qpz', + action: 'SWAP|0|BTC|GIVE|10', privateKey: {}, networkId: 'simnet' }), + /Storage mass exceeds maximum[\s\S]*attempt 3\/3/, + 'a malformed action must still fail, with the attempt count visible'); + assert.strictEqual(calls, 3, 'exactly three: a bounded retry, not a loop'); + }); +});