From a6d77193e5182193b362115d28decb725ebf85fa Mon Sep 17 00:00:00 2001 From: Matt Hess Date: Fri, 13 Feb 2026 19:22:48 +0000 Subject: [PATCH] Fix HF5 burn-in crash + add block privacy extractor tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark HF5 as paused in testnet fork table — SHUTDOWN_USER_TXS disables all user transactions at this fork level, causing the burn-in to fail with a silent daemon rejection - Increase transfer rejection error detail from 200→800 chars so daemon errors are no longer hidden behind "(response too large)" - Bump wallet sync MAX_BATCH_SIZE 500→1000 for faster testnet syncs - Add tools/block-privacy-extract.js — era-aware extractor that shows real encrypted data from Salvium blocks (stealth addresses, ring signatures, Bulletproof+ proofs, Pedersen commitments) with correct annotations per fork level (1-byte vs 3-byte view tags, CLSAG vs TCLSAG, BulletproofPlus vs SalviumOne RCT types) --- src/wallet-sync.js | 2 +- src/wallet/transfer.js | 2 +- test/full-testnet.js | 5 +- tools/block-privacy-extract.js | 482 +++++++++++++++++++++++++++++++++ 4 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 tools/block-privacy-extract.js diff --git a/src/wallet-sync.js b/src/wallet-sync.js index 809c697..e6a876e 100644 --- a/src/wallet-sync.js +++ b/src/wallet-sync.js @@ -42,7 +42,7 @@ export const MIN_BATCH_SIZE = 2; /** * Maximum batch size (ceiling) - prevent memory/timeout issues */ -export const MAX_BATCH_SIZE = 500; +export const MAX_BATCH_SIZE = 1000; /** * Maximum concurrent RPC calls for parallel block fetching diff --git a/src/wallet/transfer.js b/src/wallet/transfer.js index aa04b8e..a7ddd57 100644 --- a/src/wallet/transfer.js +++ b/src/wallet/transfer.js @@ -187,7 +187,7 @@ function extractRejectionReason(respData) { const status = respData?.status || 'unknown'; try { const detail = JSON.stringify(respData, null, 0); - return detail.length > 200 ? `${status} (response too large)` : `${status}: ${detail}`; + return detail.length > 800 ? `${status}: ${detail.slice(0, 800)}...` : `${status}: ${detail}`; } catch (_e) { return status; } diff --git a/test/full-testnet.js b/test/full-testnet.js index f72811f..bb3085a 100644 --- a/test/full-testnet.js +++ b/test/full-testnet.js @@ -40,7 +40,7 @@ const FORKS = [ { hf: 2, height: 250, asset: 'SAL', addrFormat: 'legacy', fullTests: true }, { hf: 3, height: 500, asset: 'SAL', addrFormat: 'legacy' }, { hf: 4, height: 600, asset: 'SAL', addrFormat: 'legacy' }, - { hf: 5, height: 800, asset: 'SAL', addrFormat: 'legacy' }, + { hf: 5, height: 800, asset: 'SAL', addrFormat: 'legacy', paused: true }, // SHUTDOWN_USER_TXS — daemon rejects all user TXs { hf: 6, height: 815, asset: 'SAL1', addrFormat: 'legacy', fullTests: true }, { hf: 7, height: 900, asset: 'SAL1', addrFormat: 'legacy' }, { hf: 8, height: 950, asset: 'SAL1', addrFormat: 'legacy' }, @@ -374,6 +374,9 @@ async function runForkTests(fork, daemon, daemonUrl, walletA, walletB) { await syncWallet(walletB, daemon, 'B'); await doSweep(walletB, walletB.getAddress(), `HF${fork.hf} sweep B→B`, AT); } + } else if (fork.paused) { + // Paused forks (HF5, HF7, HF9): daemon rejects user transactions + console.log(` (HF${fork.hf} paused — user transactions disabled by daemon, WASM probe only)`); } else if (fork.hf > 1) { // Lightweight transfer test at intermediate forks (skip HF1 — no mature outputs yet) await doTransfer(walletA, walletB, sal(0.5), `HF${fork.hf} A→B 0.5 ${AT}`, { legacy, assetType: AT }); diff --git a/tools/block-privacy-extract.js b/tools/block-privacy-extract.js new file mode 100644 index 0000000..876acc1 --- /dev/null +++ b/tools/block-privacy-extract.js @@ -0,0 +1,482 @@ +#!/usr/bin/env bun +/** + * Salvium Block Privacy Extractor + * + * Extracts real cryptographic data from Salvium blocks to demonstrate + * the privacy features of the blockchain. Shows what the public sees + * (encrypted data, ring signatures, stealth addresses) vs what Bitcoin + * would reveal (clear addresses, amounts). + * + * Usage: + * bun tools/block-privacy-extract.js [--daemon URL] [--height N] [--count N] [--format json|visual] + */ + +import { DaemonRPC } from '../src/rpc/daemon.js'; +import { parseTransaction } from '../src/transaction/parsing.js'; +import { hexToBytes, bytesToHex } from '../src/address.js'; +import { TX_TYPE, RCT_TYPE, TXOUT_TYPE, TXIN_TYPE } from '../src/transaction/constants.js'; + +// ─── CLI Args ────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +function getArg(name, fallback) { + const idx = args.indexOf(name); + return idx >= 0 && args[idx + 1] ? args[idx + 1] : fallback; +} + +const DAEMON_URL = getArg('--daemon', 'http://seed01.salvium.io:19081'); +const START_HEIGHT = parseInt(getArg('--height', '0'), 10); // 0 = latest +const BLOCK_COUNT = parseInt(getArg('--count', '3'), 10); +const FORMAT = getArg('--format', 'visual'); // 'visual' or 'json' + +// ─── Helpers ─────────────────────────────────────────────────────────────── + +const TX_TYPE_NAMES = Object.fromEntries(Object.entries(TX_TYPE).map(([k, v]) => [v, k])); +const RCT_TYPE_NAMES = Object.fromEntries(Object.entries(RCT_TYPE).map(([k, v]) => [v, k])); + +function outTypeName(type) { + if (type === 0x04) return 'CARROT_V1'; + if (type === 0x03) return 'TAGGED_KEY'; + if (type === 0x02) return 'KEY'; + return `0x${type.toString(16)}`; +} + +function truncHex(hex, len = 16) { + return hex; // Show full strings for commercial/demo use +} + +function toHex(u8) { + if (!u8) return '(null)'; + if (typeof u8 === 'string') return u8; + return bytesToHex(u8); +} + +function fmtBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; +} + +// ─── Data Extraction ─────────────────────────────────────────────────────── + +function extractTxPrivacyData(tx, txHash) { + const p = tx.prefix || tx; + const rct = tx.rct || {}; + + // Inputs: key images + ring members + const inputs = (p.vin || []).map((inp, i) => { + if (inp.type === TXIN_TYPE.GEN) { + return { + index: i, + type: 'coinbase', + height: Number(inp.height || inp.amount || 0) + }; + } + return { + index: i, + type: 'key', + keyImage: toHex(inp.keyImage), + ringSize: inp.keyOffsets ? inp.keyOffsets.length : 0, + ringOffsets: inp.keyOffsets || [], + amount: '0 (hidden by RingCT)', + assetType: inp.assetType || 'SAL' + }; + }); + + // Outputs: stealth addresses, view tags, encrypted amounts + const outputs = (p.vout || []).map((out, i) => { + const ecdhAmount = rct.ecdhInfo && rct.ecdhInfo[i] + ? toHex(rct.ecdhInfo[i].amount) + : null; + const commitment = rct.outPk && rct.outPk[i] + ? toHex(rct.outPk[i]) + : null; + + const o = { + index: i, + outputType: outTypeName(out.type), + stealthAddress: toHex(out.key), + amount: '(encrypted)', + assetType: out.assetType || 'SAL' + }; + + if (out.viewTag !== undefined && out.viewTag !== null) { + o.viewTag = typeof out.viewTag === 'number' + ? `0x${out.viewTag.toString(16).padStart(2, '0')}` + : toHex(out.viewTag); + } + if (out.encryptedJanusAnchor) { + o.encryptedJanusAnchor = toHex(out.encryptedJanusAnchor); + } + if (ecdhAmount) o.encryptedAmount = ecdhAmount; + if (commitment) o.pedersenCommitment = commitment; + + return o; + }); + + // Extra field: ephemeral keys + const extra = {}; + if (p.extra) { + for (const field of p.extra) { + if (field.tag === 'tx_pubkey') { + extra.ephemeralPubkey = toHex(field.key); + } else if (field.tag === 'additional_pubkeys') { + extra.additionalPubkeys = (field.keys || []).map(k => toHex(k)); + } else if (field.tag === 'nonce' && field.paymentId) { + extra.encryptedPaymentId = toHex(field.paymentId); + } + } + } + + // Ring signatures + const ringSignatures = {}; + if (rct.CLSAGs && rct.CLSAGs.length > 0) { + ringSignatures.type = 'CLSAG'; + ringSignatures.count = rct.CLSAGs.length; + ringSignatures.sample = { + s_responses: (rct.CLSAGs[0].s || []).map(s => toHex(s)), + challenge_c1: toHex(rct.CLSAGs[0].c1), + D: toHex(rct.CLSAGs[0].D) + }; + } else if (rct.TCLSAGs && rct.TCLSAGs.length > 0) { + ringSignatures.type = 'TCLSAG (Twin)'; + ringSignatures.count = rct.TCLSAGs.length; + ringSignatures.sample = { + sx_responses: (rct.TCLSAGs[0].sx || []).map(s => toHex(s)), + sy_responses: (rct.TCLSAGs[0].sy || []).map(s => toHex(s)), + challenge_c1: toHex(rct.TCLSAGs[0].c1), + D: toHex(rct.TCLSAGs[0].D) + }; + } + + // Range proofs + const rangeProofs = {}; + if (rct.bulletproofPlus && rct.bulletproofPlus.length > 0) { + const bp = rct.bulletproofPlus[0]; + rangeProofs.type = 'Bulletproof+'; + rangeProofs.count = rct.bulletproofPlus.length; + rangeProofs.sample = { + A: toHex(bp.A), + A1: toHex(bp.A1), + B: toHex(bp.B), + r1: toHex(bp.r1), + s1: toHex(bp.s1), + d1: toHex(bp.d1), + L_count: (bp.L || []).length, + R_count: (bp.R || []).length + }; + if (bp.L && bp.L.length > 0) { + rangeProofs.sample.L = bp.L.map(v => toHex(v)); + rangeProofs.sample.R = bp.R.map(v => toHex(v)); + } + } + + // Pseudo-outputs (input commitments) + const pseudoOuts = (rct.pseudoOuts || []).map(p => toHex(p)); + + return { + txHash, + txType: TX_TYPE_NAMES[p.txType] || `UNKNOWN(${p.txType})`, + rctType: RCT_TYPE_NAMES[rct.type] || `UNKNOWN(${rct.type})`, + fee: rct.txnFee ? `${Number(rct.txnFee)} atomic` : '0 (coinbase)', + version: p.version, + inputCount: inputs.length, + outputCount: outputs.length, + inputs, + outputs, + extra, + ringSignatures, + rangeProofs, + pseudoOuts, + // Salvium-specific + ...(p.amount_burnt ? { amountBurnt: `${p.amount_burnt} (hidden)` } : {}), + ...(p.source_asset_type ? { sourceAsset: p.source_asset_type } : {}), + ...(p.destination_asset_type ? { destAsset: p.destination_asset_type } : {}) + }; +} + +// ─── Visual Formatting ──────────────────────────────────────────────────── + +const DIM = '\x1b[2m'; +const CYAN = '\x1b[36m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const RED = '\x1b[31m'; +const MAGENTA = '\x1b[35m'; +const BOLD = '\x1b[1m'; +const RESET = '\x1b[0m'; + +function printVisual(blockData, txDataList) { + const hdr = blockData.header; + + console.log(`\n${BOLD}${CYAN}${'═'.repeat(80)}${RESET}`); + console.log(`${BOLD}${CYAN} BLOCK ${hdr.height}${RESET} ${DIM}hash: ${hdr.hash}${RESET}`); + console.log(`${DIM} timestamp: ${new Date(hdr.timestamp * 1000).toISOString()} | txns: ${hdr.num_txes} | reward: ${(hdr.reward / 1e8).toFixed(8)} SAL${RESET}`); + console.log(`${CYAN}${'─'.repeat(80)}${RESET}`); + + for (const txd of txDataList) { + console.log(`\n ${BOLD}TX ${truncHex(txd.txHash, 20)}${RESET}`); + // Infer era from RCT type + const era = txd.rctType === 'SalviumOne' ? 'CARROT' + : txd.rctType === 'SalviumZero' ? 'SAL1' + : txd.rctType === 'FullProofs' ? 'HF3-5' + : txd.rctType === 'BulletproofPlus' ? 'HF1-2' + : ''; + const eraTag = era ? ` era: ${CYAN}${era}${RESET}${DIM}` : ''; + console.log(` ${DIM}type: ${YELLOW}${txd.txType}${RESET}${DIM} rct: ${txd.rctType}${eraTag} fee: ${txd.fee}${RESET}`); + + // --- Inputs --- + if (txd.inputs.length > 0) { + console.log(`\n ${GREEN}INPUTS (${txd.inputs.length})${RESET} ${DIM}— what was spent (hidden by ring signatures)${RESET}`); + for (const inp of txd.inputs) { + if (inp.type === 'coinbase') { + console.log(` ${DIM}[${inp.index}]${RESET} coinbase (block reward, height ${inp.height})`); + } else { + console.log(` ${DIM}[${inp.index}]${RESET} ${RED}Key Image:${RESET} ${DIM}${truncHex(inp.keyImage, 24)}${RESET}`); + console.log(` ${DIM}Ring size: ${YELLOW}${inp.ringSize} decoys${RESET}${DIM} — observer cannot determine real input${RESET}`); + if (inp.ringOffsets.length > 0) { + const offsets = inp.ringOffsets.slice(0, 6).join(', '); + const more = inp.ringOffsets.length > 6 ? `, ... +${inp.ringOffsets.length - 6} more` : ''; + console.log(` ${DIM}Ring offsets: [${offsets}${more}]${RESET}`); + } + } + } + } + + // --- Outputs --- + if (txd.outputs.length > 0) { + console.log(`\n ${MAGENTA}OUTPUTS (${txd.outputs.length})${RESET} ${DIM}— recipient addresses (stealth, one-time use)${RESET}`); + for (const out of txd.outputs) { + console.log(` ${DIM}[${out.index}]${RESET} ${BOLD}${out.outputType}${RESET} ${DIM}(${out.assetType})${RESET}`); + console.log(` Stealth Address: ${CYAN}${truncHex(out.stealthAddress, 24)}${RESET}`); + if (out.viewTag) { + const vtSize = out.outputType === 'CARROT_V1' ? '3-byte' : '1-byte'; + console.log(` View Tag: ${YELLOW}${out.viewTag}${RESET} ${DIM}(${vtSize} scan filter — reveals nothing about recipient)${RESET}`); + } + if (out.encryptedJanusAnchor) { + console.log(` Janus Anchor: ${RED}${truncHex(out.encryptedJanusAnchor, 16)}${RESET} ${DIM}(encrypted verification data)${RESET}`); + } + if (out.encryptedAmount) { + console.log(` Encrypted Amt: ${RED}${out.encryptedAmount}${RESET} ${DIM}(8 bytes, only recipient can decrypt)${RESET}`); + } + if (out.pedersenCommitment) { + console.log(` Commitment: ${DIM}${truncHex(out.pedersenCommitment, 24)}${RESET} ${DIM}(Pedersen: hides amount, proves validity)${RESET}`); + } + } + } + + // --- Ephemeral Keys --- + if (txd.extra.ephemeralPubkey) { + console.log(`\n ${YELLOW}EPHEMERAL KEY${RESET} ${DIM}— Diffie-Hellman exchange (one-time, unlinkable)${RESET}`); + console.log(` tx_pubkey: ${DIM}${txd.extra.ephemeralPubkey}${RESET}`); + if (txd.extra.additionalPubkeys && txd.extra.additionalPubkeys.length > 0) { + console.log(` + ${txd.extra.additionalPubkeys.length} additional pubkeys (for subaddresses)`); + } + } + + // --- Ring Signatures --- + if (txd.ringSignatures.type) { + console.log(`\n ${RED}RING SIGNATURES (${txd.ringSignatures.type})${RESET} ${DIM}— proves authorization without revealing signer${RESET}`); + console.log(` ${txd.ringSignatures.count} signature(s)`); + const sig = txd.ringSignatures.sample; + if (sig.s_responses) { + console.log(` Challenge c₁: ${DIM}${truncHex(sig.challenge_c1, 24)}${RESET}`); + console.log(` Responses (${sig.s_responses.length}):`); + for (let i = 0; i < sig.s_responses.length; i++) { + console.log(` s[${i.toString().padStart(2)}]: ${DIM}${truncHex(sig.s_responses[i], 24)}${RESET}`); + } + console.log(` D (linking tag): ${DIM}${truncHex(sig.D, 24)}${RESET}`); + } + if (sig.sx_responses) { + console.log(` Challenge c₁: ${DIM}${truncHex(sig.challenge_c1, 24)}${RESET}`); + console.log(` Twin responses X (${sig.sx_responses.length}):`); + for (let i = 0; i < sig.sx_responses.length; i++) { + console.log(` sx[${i.toString().padStart(2)}]: ${DIM}${truncHex(sig.sx_responses[i], 24)}${RESET}`); + } + console.log(` Twin responses Y (${sig.sy_responses.length}):`); + for (let i = 0; i < sig.sy_responses.length; i++) { + console.log(` sy[${i.toString().padStart(2)}]: ${DIM}${truncHex(sig.sy_responses[i], 24)}${RESET}`); + } + } + } + + // --- Range Proofs --- + if (txd.rangeProofs.type) { + console.log(`\n ${GREEN}RANGE PROOFS (${txd.rangeProofs.type})${RESET} ${DIM}— proves amounts are valid (0..2⁶⁴) without revealing them${RESET}`); + const rp = txd.rangeProofs.sample; + console.log(` ${txd.rangeProofs.count} proof(s), ${rp.L_count} inner product rounds each`); + console.log(` A: ${DIM}${truncHex(rp.A, 24)}${RESET}`); + console.log(` A1: ${DIM}${truncHex(rp.A1, 24)}${RESET}`); + console.log(` B: ${DIM}${truncHex(rp.B, 24)}${RESET}`); + if (rp.L && rp.L.length > 0) { + for (let i = 0; i < rp.L.length; i++) { + console.log(` L[${i}]: ${DIM}${truncHex(rp.L[i], 24)}${RESET}`); + console.log(` R[${i}]: ${DIM}${truncHex(rp.R[i], 24)}${RESET}`); + } + } + console.log(` r1: ${DIM}${truncHex(rp.r1, 24)}${RESET}`); + console.log(` s1: ${DIM}${truncHex(rp.s1, 24)}${RESET}`); + } + + // --- Pseudo-outputs --- + if (txd.pseudoOuts.length > 0) { + console.log(`\n ${DIM}PSEUDO-OUTPUTS (${txd.pseudoOuts.length}) — input commitments (balance proof)${RESET}`); + for (const po of txd.pseudoOuts) { + console.log(` ${DIM}${truncHex(po, 24)}${RESET}`); + } + } + } + + console.log(`\n${CYAN}${'═'.repeat(80)}${RESET}\n`); +} + +function printComparison() { + console.log(`\n${BOLD}${YELLOW} SALVIUM vs BITCOIN — What the public sees${RESET}\n`); + console.log(` ${'─'.repeat(72)}`); + console.log(` ${BOLD}Field${RESET} ${BOLD}Bitcoin${RESET} ${BOLD}Salvium${RESET}`); + console.log(` ${'─'.repeat(72)}`); + console.log(` Sender address ${RED}VISIBLE${RESET} (input scripts) ${GREEN}HIDDEN${RESET} (ring sigs, 16 decoys)`); + console.log(` Recipient address ${RED}VISIBLE${RESET} (output scripts) ${GREEN}HIDDEN${RESET} (stealth addresses)`); + console.log(` Amount transferred ${RED}VISIBLE${RESET} (plaintext satoshi) ${GREEN}ENCRYPTED${RESET} (RingCT + Pedersen)`); + console.log(` Transaction linkage ${RED}TRACEABLE${RESET} (UTXO graph) ${GREEN}UNLINKABLE${RESET} (key images)`); + console.log(` Address reuse ${RED}COMMON${RESET} (same addr visible) ${GREEN}IMPOSSIBLE${RESET} (one-time keys)`); + console.log(` Balance ${RED}COMPUTABLE${RESET} (sum UTXOs) ${GREEN}HIDDEN${RESET} (only owner knows)`); + console.log(` Tx graph analysis ${RED}POSSIBLE${RESET} (chain analysis) ${GREEN}DEFEATED${RESET} (ring + stealth + CT)`); + console.log(` ${'─'.repeat(72)}\n`); +} + +// ─── Main ────────────────────────────────────────────────────────────────── + +async function main() { + const daemon = new DaemonRPC({ url: DAEMON_URL }); + + // Get chain height + const info = await daemon.getInfo(); + if (!info.success) { + console.error('Failed to connect to daemon:', info.error?.message || 'unknown error'); + console.error('URL:', DAEMON_URL); + process.exit(1); + } + + const chainHeight = info.result.height; + const startAt = START_HEIGHT > 0 ? START_HEIGHT : Math.max(1, chainHeight - BLOCK_COUNT); + + const log = FORMAT === 'json' ? console.error : console.log; + log(`${BOLD}Salvium Block Privacy Extractor${RESET}`); + log(`${DIM}Daemon: ${DAEMON_URL} Chain height: ${chainHeight}${RESET}`); + log(`${DIM}Extracting blocks ${startAt} to ${startAt + BLOCK_COUNT - 1}...${RESET}`); + + if (FORMAT === 'visual') { + printComparison(); + } + + const allBlocks = []; + const allVisualBlocks = []; // for summary stats + + for (let h = startAt; h < startAt + BLOCK_COUNT && h < chainHeight; h++) { + // Fetch block header + blob + const blockResp = await daemon.getBlock({ height: h }); + if (!blockResp.success) { + console.error(`Failed to fetch block ${h}:`, blockResp.error?.message); + continue; + } + + const block = blockResp.result; + const hdr = block.block_header; + const txDataList = []; + + // Parse the miner transaction from the block blob + try { + const blockBlob = hexToBytes(block.blob); + const parsed = (await import('../src/transaction/parsing.js')).parseBlock(blockBlob); + + if (parsed.minerTx) { + txDataList.push(extractTxPrivacyData(parsed.minerTx, hdr.miner_tx_hash)); + } + if (parsed.protocolTx && parsed.protocolTx.prefix && parsed.protocolTx.prefix.vout && parsed.protocolTx.prefix.vout.length > 0) { + txDataList.push(extractTxPrivacyData(parsed.protocolTx, hdr.protocol_tx_hash || 'protocol_tx')); + } + } catch (e) { + console.error(` ${DIM}(block blob parse failed: ${e.message})${RESET}`); + } + + // Fetch user transactions from the block + const userTxHashes = block.tx_hashes || []; + if (userTxHashes.length > 0) { + const txResp = await daemon.getTransactions(userTxHashes); + if (txResp.success && txResp.result.txs) { + for (const txEntry of txResp.result.txs) { + try { + const txData = hexToBytes(txEntry.as_hex); + const parsed = parseTransaction(txData); + txDataList.push(extractTxPrivacyData(parsed, txEntry.tx_hash)); + } catch (e) { + console.error(` ${DIM}(tx parse failed: ${e.message})${RESET}`); + } + } + } + } + + if (FORMAT === 'json') { + allBlocks.push({ + height: hdr.height, + hash: hdr.hash, + timestamp: hdr.timestamp, + reward: hdr.reward, + numTxes: hdr.num_txes, + transactions: txDataList + }); + } else { + allVisualBlocks.push(txDataList); + printVisual({ header: hdr }, txDataList); + } + } + + if (FORMAT === 'json') { + console.log(JSON.stringify(allBlocks, null, 2)); + } + + // Summary — data-driven from what was actually extracted + if (FORMAT === 'visual') { + const stats = { blocks: 0, carrot: 0, tagged: 0, legacy: 0, clsag: 0, tclsag: 0, rctTypes: new Set() }; + for (const blk of allVisualBlocks) { + stats.blocks++; + for (const tx of blk) { + if (tx.rctType !== 'Null') stats.rctTypes.add(tx.rctType); + if (tx.ringSignatures.type === 'CLSAG') stats.clsag += tx.ringSignatures.count || 0; + if (tx.ringSignatures.type === 'TCLSAG (Twin)') stats.tclsag += tx.ringSignatures.count || 0; + for (const out of tx.outputs) { + if (out.outputType === 'CARROT_V1') stats.carrot++; + else if (out.outputType === 'TAGGED_KEY') stats.tagged++; + else stats.legacy++; + } + } + } + + const ringSigType = stats.tclsag > 0 && stats.clsag > 0 ? 'CLSAG + TCLSAG' + : stats.tclsag > 0 ? 'TCLSAG (Twin)' : 'CLSAG'; + + console.log(`${BOLD}${CYAN} SUMMARY${RESET}`); + console.log(` ${DIM}Blocks extracted: ${stats.blocks} | Daemon: ${DAEMON_URL}${RESET}`); + console.log(` ${DIM}Every output uses a unique stealth address — no address reuse possible${RESET}`); + console.log(` ${DIM}Every amount is encrypted with Pedersen commitments + Bulletproof+ proofs${RESET}`); + console.log(` ${DIM}Every input is hidden among 16 decoys via ${ringSigType} ring signatures${RESET}`); + if (stats.carrot > 0) { + console.log(` ${DIM}CARROT outputs (${stats.carrot}): 3-byte view tags + encrypted Janus anchors${RESET}`); + } + if (stats.tagged > 0) { + console.log(` ${DIM}Tagged-key outputs (${stats.tagged}): 1-byte view tags + stealth keys${RESET}`); + } + if (stats.legacy > 0) { + console.log(` ${DIM}Legacy key outputs (${stats.legacy}): stealth keys only${RESET}`); + } + if (stats.rctTypes.size > 0) { + console.log(` ${DIM}RCT types seen: ${[...stats.rctTypes].join(', ')}${RESET}`); + } + console.log(); + } +} + +main().catch(err => { + console.error('Fatal:', err.message); + process.exit(1); +});