diff --git a/src/bulletproofs_plus.js b/src/bulletproofs_plus.js index 4e67abf..27800c5 100644 --- a/src/bulletproofs_plus.js +++ b/src/bulletproofs_plus.js @@ -805,7 +805,15 @@ export function bulletproofPlusProve(amounts, masks) { // Try accelerated backend (WASM/JSI) const backend = getCryptoBackend(); const nativeResult = backend.bulletproofPlusProve(amounts, masks); - if (nativeResult !== null) return nativeResult; + if (nativeResult !== null) { + // WASM/JSI returns { V, proofBytes } — parse into full field set + // so serializeProof and getPreMlsagHash can access A, A1, B, etc. + if (nativeResult.proofBytes && !nativeResult.A) { + const parsed = parseProof(nativeResult.proofBytes); + return { V: nativeResult.V, ...parsed, proofBytes: nativeResult.proofBytes }; + } + return nativeResult; + } // Compute M (smallest power of 2 >= amounts.length) let M = 1; @@ -1085,6 +1093,9 @@ export function bulletproofPlusProve(amounts, masks) { * Serialize a Bulletproof+ proof to bytes */ export function serializeProof(proof) { + // Short-circuit: WASM/JSI backend already provides serialized bytes + if (proof.proofBytes) return proof.proofBytes; + const { A, A1, B, r1, s1, d1, L, R } = proof; // Monero/Salvium binary format for BulletproofPlus: diff --git a/src/crypto/backend-wasm.js b/src/crypto/backend-wasm.js index d1dcd39..113c5a2 100644 --- a/src/crypto/backend-wasm.js +++ b/src/crypto/backend-wasm.js @@ -315,6 +315,15 @@ function bytesToHex(bytes) { function ensureBytes(v) { if (typeof v === 'string') return hexToBytes(v); + if (typeof v === 'bigint') { + const bytes = new Uint8Array(32); + let n = v; + for (let i = 0; i < 32; i++) { + bytes[i] = Number(n & 0xffn); + n >>= 8n; + } + return bytes; + } return v; } diff --git a/test/consensus-helpers.test.js b/test/consensus-helpers.test.js index e379fb3..02e6020 100644 --- a/test/consensus-helpers.test.js +++ b/test/consensus-helpers.test.js @@ -55,8 +55,8 @@ describe('getMedianBlockWeight', () => { expect(getMedianBlockWeight([1, 3, 5])).toBe(3); }); - test('even-length array returns floor of average', () => { - expect(getMedianBlockWeight([1, 3, 5, 7])).toBe(4); + test('even-length array returns upper median', () => { + expect(getMedianBlockWeight([1, 3, 5, 7])).toBe(5); }); test('unsorted input is handled', () => { @@ -71,7 +71,7 @@ describe('getMedianBlockWeight', () => { test('large dataset', () => { const values = Array.from({ length: 1000 }, (_, i) => i + 1); - expect(getMedianBlockWeight(values)).toBe(500); + expect(getMedianBlockWeight(values)).toBe(501); }); test('all same values', () => { diff --git a/test/crypto-provider.test.js b/test/crypto-provider.test.js index 9760d9c..2f20330 100644 --- a/test/crypto-provider.test.js +++ b/test/crypto-provider.test.js @@ -428,10 +428,14 @@ console.log('\n=== Pedersen Commitment Equivalence ===\n'); const H_HEX = '8b655970153799af2aeadc9ff1add0ea6c7251d54154cfa92c173a0dd39c1f94'; -await asyncTest('zeroCommit(1) = H', async () => { +await asyncTest('zeroCommit(1) = G + H (Salvium uses blinding factor 1)', async () => { const js = new JsCryptoBackend(); const result = js.zeroCommit(1n); - assertEqual(result, hexToBytes(H_HEX), 'zeroCommit(1) should be H'); + // Salvium zeroCommit uses mask=1, so zeroCommit(1) = 1*G + 1*H + const G = js.scalarMultBase(new Uint8Array([1, ...new Array(31).fill(0)])); + const H = hexToBytes(H_HEX); + const expected = js.pointAddCompressed(G, H); + assertEqual(result, expected, 'zeroCommit(1) should be G + H'); }); await asyncTest('commit equivalence (random)', async () => { @@ -462,15 +466,19 @@ await asyncTest('genCommitmentMask equivalence', async () => { assertEqual(jsResult, wasmResult, 'genCommitmentMask JS vs WASM'); }); -await asyncTest('commit homomorphic: commit(a,m) - zeroCommit(a) = m*G', async () => { +await asyncTest('commit homomorphic: commit(a,m) - zeroCommit(a) = (m-1)*G', async () => { const js = new JsCryptoBackend(); const amount = 42n; const mask = scalarA; const c = js.commit(amount, mask); const z = js.zeroCommit(amount); + // commit(a,m) = m*G + a*H, zeroCommit(a) = 1*G + a*H + // diff = (m-1)*G const diff = js.pointSubCompressed(c, z); - const mG = js.scalarMultBase(mask); - assertEqual(diff, mG, 'homomorphic property'); + const scalarOne = new Uint8Array(32); scalarOne[0] = 1; + const mMinusOne = js.scSub(mask, scalarOne); + const expected = js.scalarMultBase(mMinusOne); + assertEqual(diff, expected, 'homomorphic property'); }); // ─── Benchmark ────────────────────────────────────────────────────────────── diff --git a/test/dynamic-block-size.test.js b/test/dynamic-block-size.test.js index 65abfdd..7f3beab 100644 --- a/test/dynamic-block-size.test.js +++ b/test/dynamic-block-size.test.js @@ -149,12 +149,12 @@ describe('ChainState dynamic block size integration', () => { }); describe('getMedianBlockWeight edge cases', () => { - test('two elements', () => { - expect(getMedianBlockWeight([10, 20])).toBe(15); + test('two elements (upper median)', () => { + expect(getMedianBlockWeight([10, 20])).toBe(20); }); - test('large spread', () => { - expect(getMedianBlockWeight([1, 1000000])).toBe(500000); + test('large spread (upper median)', () => { + expect(getMedianBlockWeight([1, 1000000])).toBe(1000000); }); test('duplicate values', () => { diff --git a/test/tx-diag.js b/test/tx-diag.js new file mode 100644 index 0000000..b109547 --- /dev/null +++ b/test/tx-diag.js @@ -0,0 +1,299 @@ +#!/usr/bin/env bun +/** + * TX Diagnostic — builds a minimal transfer, serializes, parses back, submits + */ + +import { setCryptoBackend } from '../src/crypto/index.js'; +import { DaemonRPC } from '../src/rpc/daemon.js'; +import { Wallet } from '../src/wallet.js'; +import { parseTransaction, serializeTransaction } from '../src/transaction.js'; +import { bytesToHex, hexToBytes } from '../src/address.js'; +import { loadWalletFromFile, getHeight } from './test-helpers.js'; + +await setCryptoBackend('wasm'); + +const DAEMON_URL = 'http://web.whiskymine.io:29081'; +const NETWORK = 'testnet'; +const WALLET_A_FILE = `${process.env.HOME}/testnet-wallet/wallet-a.json`; +const SYNC_CACHE_A = WALLET_A_FILE.replace(/\.json$/, '-sync.json'); + +const daemon = new DaemonRPC({ url: DAEMON_URL }); + +async function main() { + const h = await getHeight(daemon); + console.log(`Chain height: ${h}`); + console.log(); + + // Load wallet + const walletA = await loadWalletFromFile(WALLET_A_FILE, NETWORK); + walletA.setDaemon(daemon); + + // Load sync cache + const { existsSync } = await import('node:fs'); + if (existsSync(SYNC_CACHE_A)) { + const cached = JSON.parse(await Bun.file(SYNC_CACHE_A).text()); + walletA.loadSyncCache(cached); + } + + // Sync + console.log('Syncing wallet...'); + await walletA.syncWithDaemon(); + await Bun.write(SYNC_CACHE_A, walletA.dumpSyncCacheJSON()); + + const { balance, unlockedBalance } = await walletA.getStorageBalance(); + console.log(`Balance: ${balance}, Unlocked: ${unlockedBalance}`); + + if (unlockedBalance === 0n) { + console.log('No spendable balance. Need more blocks to mature.'); + return; + } + + // Build a self-transfer (A -> A) for diagnosis + const addr = walletA.getLegacyAddress(); + console.log(`\nBuilding self-transfer: 0.1 SAL to ${addr.slice(0,20)}...`); + + try { + const result = await walletA.transfer( + [{ address: addr, amount: 10_000_000n }], + { priority: 'default', dryRun: true } + ); + + console.log('\n=== TX BUILT SUCCESSFULLY (dry run) ==='); + console.log(`TX hash: ${result.txHash}`); + console.log(`Fee: ${result.fee}`); + console.log(`Inputs: ${result.inputCount}`); + console.log(`Outputs: ${result.outputCount}`); + + const tx = result.tx; + const prefix = tx.prefix; + console.log(`\n--- TX PREFIX ---`); + console.log(`Version: ${prefix.version}`); + console.log(`Unlock: ${prefix.unlockTime}`); + console.log(`TX type: ${prefix.txType}`); + console.log(`Src asset: ${prefix.source_asset_type}`); + console.log(`Dst asset: ${prefix.destination_asset_type}`); + console.log(`Amount burnt: ${prefix.amount_burnt}`); + console.log(`Inputs: ${prefix.vin.length}`); + console.log(`Outputs: ${prefix.vout.length}`); + + for (let i = 0; i < prefix.vin.length; i++) { + const inp = prefix.vin[i]; + console.log(` vin[${i}]: type=${inp.type}, amount=${inp.amount}, asset=${inp.assetType}, offsets=[${inp.keyOffsets.slice(0,3)}...], ki=${bytesToHex(inp.keyImage).slice(0,16)}...`); + } + + for (let i = 0; i < prefix.vout.length; i++) { + const out = prefix.vout[i]; + const target = typeof out.target === 'string' ? out.target.slice(0,16) : bytesToHex(out.target).slice(0,16); + console.log(` vout[${i}]: type=0x${out.type.toString(16)}, amount=${out.amount}, asset=${out.assetType}, viewTag=${out.viewTag}, target=${target}...`); + } + + // Extra + const extra = prefix.extra; + if (extra?.txPubKey) { + const pk = typeof extra.txPubKey === 'string' ? extra.txPubKey : bytesToHex(extra.txPubKey); + console.log(` extra.txPubKey: ${pk.slice(0,16)}...`); + } + + // Return address fields + if (prefix.return_address_list) { + console.log(` return_address_list: ${prefix.return_address_list.length} F-points`); + for (let i = 0; i < prefix.return_address_list.length; i++) { + const fp = prefix.return_address_list[i]; + const fpHex = typeof fp === 'string' ? fp : bytesToHex(fp); + console.log(` F[${i}]: ${fpHex.slice(0,16)}...`); + } + } + if (prefix.return_address_change_mask) { + console.log(` return_address_change_mask: [${Array.from(prefix.return_address_change_mask).join(', ')}]`); + } + if (prefix.return_address) { + const ra = typeof prefix.return_address === 'string' ? prefix.return_address : bytesToHex(prefix.return_address); + console.log(` return_address: ${ra.slice(0,16)}...`); + } + if (prefix.return_pubkey) { + const rp = typeof prefix.return_pubkey === 'string' ? prefix.return_pubkey : bytesToHex(prefix.return_pubkey); + console.log(` return_pubkey: ${rp.slice(0,16)}...`); + } + + console.log(`\n--- RCT ---`); + const rct = tx.rct; + console.log(`Type: ${rct.type}`); + console.log(`Fee: ${rct.fee}`); + console.log(`ecdhInfo: ${rct.ecdhInfo.length} entries`); + console.log(`outPk: ${rct.outPk.length} entries`); + console.log(`pseudoOuts: ${rct.pseudoOuts.length} entries`); + console.log(`p_r: ${typeof rct.p_r === 'string' ? rct.p_r.slice(0,16) : bytesToHex(rct.p_r).slice(0,16)}...`); + console.log(`CLSAGs: ${rct.CLSAGs?.length || 0}`); + console.log(`TCLSAGs: ${rct.TCLSAGs?.length || 0}`); + console.log(`BP+: ${rct.bulletproofPlus ? 'yes' : 'no'}`); + + if (rct.salvium_data) { + console.log(`salvium_data: type=${rct.salvium_data.salvium_data_type}`); + } + + // Serialize + console.log(`\n--- SERIALIZATION ---`); + const serialized = serializeTransaction(tx); + console.log(`Serialized length: ${serialized.length} bytes`); + console.log(`Serialized hex (first 200 chars): ${bytesToHex(serialized).slice(0, 200)}...`); + + // Parse back + console.log(`\n--- ROUND-TRIP PARSE ---`); + try { + const parsed = parseTransaction(serialized); + console.log(`Parse OK! Keys: ${Object.keys(parsed).join(', ')}`); + console.log(` version: ${parsed.version}`); + console.log(` type: ${parsed.type}`); + console.log(` inputs: ${parsed.vin?.length}`); + console.log(` outputs: ${parsed.vout?.length}`); + console.log(` rctType: ${parsed.rct_signatures?.type}`); + console.log(` fee: ${parsed.rct_signatures?.txnFee}`); + if (parsed._bytesRead !== serialized.length) { + console.log(` WARNING: parsed ${parsed._bytesRead} bytes but serialized is ${serialized.length} bytes!`); + console.log(` DIFF: ${serialized.length - parsed._bytesRead} extra bytes`); + // Show the extra byte(s) + const extraStart = parsed._bytesRead; + const extraBytes = serialized.slice(extraStart); + console.log(` Extra bytes at offset ${extraStart}: [${Array.from(extraBytes).map(b => '0x' + b.toString(16).padStart(2, '0')).join(', ')}]`); + } else { + console.log(` Byte count matches: ${parsed._bytesRead} == ${serialized.length}`); + } + } catch (e) { + console.log(`Parse FAILED: ${e.message}`); + console.log(e.stack); + } + + // Check _prefixEndOffset from parser + const parsed2 = parseTransaction(serialized); + console.log(`\n Parser _prefixEndOffset: ${parsed2._prefixEndOffset}`); + + // Also serialize just the prefix to check its length + const { serializeTxPrefix } = await import('../src/transaction/serialization.js'); + const prefixBytes = serializeTxPrefix({ + ...tx.prefix, + inputs: tx.prefix.vin, + outputs: tx.prefix.vout + }); + console.log(`\n--- SECTION SIZES ---`); + console.log(`Prefix: ${prefixBytes.length} bytes`); + console.log(`Parser saw prefix end at: ${parsed2._prefixEndOffset}`); + if (parsed2._prefixEndOffset !== prefixBytes.length) { + console.log(` PREFIX MISMATCH: serializer=${prefixBytes.length}, parser=${parsed2._prefixEndOffset}`); + } + + const { serializeRctBase } = await import('../src/transaction/serialization.js'); + const rctBaseBytes = serializeRctBase(tx.rct); + console.log(`RCT base: ${rctBaseBytes.length} bytes`); + console.log(`Prunable: ${serialized.length - prefixBytes.length - rctBaseBytes.length} bytes`); + + // Check what's in prunable + const prunableStart = prefixBytes.length + rctBaseBytes.length; + const prunableBytes = serialized.slice(prunableStart); + console.log(` Prunable hex (first 40): ${bytesToHex(prunableBytes).slice(0, 40)}...`); + console.log(` BP+ count varint: 0x${prunableBytes[0].toString(16)} (${prunableBytes[0]})`); + + // BP+ proof size + const bpSerialized = tx.rct.bulletproofPlus?.serialized; + console.log(` BP+ serialized: ${bpSerialized?.length || 0} bytes`); + + // CLSAG size + const { serializeCLSAG } = await import('../src/transaction/serialization.js'); + if (tx.rct.CLSAGs?.[0]) { + const clsagBytes = serializeCLSAG(tx.rct.CLSAGs[0]); + console.log(` CLSAG[0]: ${clsagBytes.length} bytes`); + } + + // pseudoOuts + console.log(` pseudoOuts: ${tx.rct.pseudoOuts.length} × 32 = ${tx.rct.pseudoOuts.length * 32} bytes`); + + // Expected prunable size + const expectedPrunable = 1 /* varint(1) for BP+ count */ + + (bpSerialized?.length || 0) + + (tx.rct.CLSAGs?.reduce((sum, sig) => sum + serializeCLSAG(sig).length, 0) || 0) + + tx.rct.pseudoOuts.length * 32; + console.log(` Expected prunable: ${expectedPrunable} bytes`); + console.log(` Actual prunable: ${prunableBytes.length} bytes`); + console.log(` Prunable diff: ${prunableBytes.length - expectedPrunable} bytes`); + + // Fetch a real coinbase TX from chain and verify our parser/serializer roundtrips + console.log(`\n--- COINBASE ROUNDTRIP TEST ---`); + try { + // Get a block to find a coinbase TX + const blockResp = await daemon.getBlockByHeight(100); + const blockData = blockResp.result || blockResp.data; + const minerTxHex = blockData?.miner_tx_hash || blockData?.block_header?.miner_tx_hash; + + // Get block details to get the miner TX hash + const blockJson = blockData?.json ? JSON.parse(blockData.json) : null; + const txHashes = blockJson?.tx_hashes || []; + console.log(` Block 100: ${txHashes.length} non-coinbase TXs`); + + // Try to get the miner TX blob from the block blob + const blockBlob = blockData?.blob; + if (blockBlob) { + const blockBytes = hexToBytes(blockBlob); + console.log(` Block blob: ${blockBytes.length} bytes`); + } + } catch (e) { + console.log(` Coinbase test failed: ${e.message}`); + } + + // Now let's do a manual hex comparison — show our prefix hex vs expected + console.log(`\n--- HEX DECOMPOSITION ---`); + const txHexFull = bytesToHex(serialized); + const prefixHex = bytesToHex(prefixBytes); + const rctBaseHex = bytesToHex(rctBaseBytes); + console.log(`Prefix (${prefixBytes.length}b): ${prefixHex.slice(0,100)}...`); + console.log(`RctBase (${rctBaseBytes.length}b): ${rctBaseHex}`); + console.log(`Prunable starts at byte ${prefixBytes.length + rctBaseBytes.length}`); + + // Manual decode of first few prefix bytes + let pos = 0; + const decodeVarIntAt = (hex, p) => { + const bytes = hexToBytes(hex); + let val = 0n, shift = 0n; + while (p < bytes.length) { + const b = BigInt(bytes[p]); + val |= (b & 0x7fn) << shift; + shift += 7n; + p++; + if ((b & 0x80n) === 0n) break; + } + return { value: val, nextPos: p }; + }; + + console.log(`\nPrefix decode:`); + let r = decodeVarIntAt(prefixHex, 0); + console.log(` version = ${r.value} (pos ${r.nextPos})`); + r = decodeVarIntAt(prefixHex, r.nextPos); + console.log(` unlockTime = ${r.value} (pos ${r.nextPos})`); + r = decodeVarIntAt(prefixHex, r.nextPos); + console.log(` vin_count = ${r.value} (pos ${r.nextPos})`); + + // Try submitting + console.log(`\n--- SUBMITTING TO DAEMON ---`); + const txHex = bytesToHex(serialized); + + // Try without sanity checks first + console.log('Submitting WITHOUT sanity checks...'); + const resp1 = await daemon.sendRawTransaction(txHex, { + source_asset_type: prefix.source_asset_type, + do_sanity_checks: false + }); + console.log(`Response: ${JSON.stringify(resp1.result || resp1.data || resp1, null, 2)}`); + + // Try with sanity checks + console.log('\nSubmitting WITH sanity checks...'); + const resp2 = await daemon.sendRawTransaction(txHex, { + source_asset_type: prefix.source_asset_type, + do_sanity_checks: true + }); + console.log(`Response: ${JSON.stringify(resp2.result || resp2.data || resp2, null, 2)}`); + + } catch (e) { + console.log(`\nERROR: ${e.message}`); + console.log(e.stack); + } +} + +main().catch(e => { console.error('FATAL:', e); process.exit(1); }); diff --git a/test/tx-verify-diag.js b/test/tx-verify-diag.js new file mode 100644 index 0000000..2a8a2f9 --- /dev/null +++ b/test/tx-verify-diag.js @@ -0,0 +1,217 @@ +#!/usr/bin/env bun +/** + * TX Verification Diagnostic — verify each component of a built TX + */ + +import { setCryptoBackend } from '../src/crypto/index.js'; +import { DaemonRPC } from '../src/rpc/daemon.js'; +import { Wallet } from '../src/wallet.js'; +import { bytesToHex, hexToBytes } from '../src/address.js'; +import { loadWalletFromFile, getHeight } from './test-helpers.js'; +import { existsSync } from 'node:fs'; +import { + clsagVerify, getPreMlsagHash, serializeRctBase, getTxPrefixHash, + commit, zeroCommit, scAdd, scSub, bytesToBigInt, bigIntToBytes +} from '../src/transaction.js'; +import { verifyBulletproofPlus } from '../src/bulletproofs_plus.js'; +import { pointAddCompressed, scalarMultBase, scalarMultPoint } from '../src/ed25519.js'; + +await setCryptoBackend('wasm'); + +const DAEMON_URL = 'http://web.whiskymine.io:29081'; +const WALLET_A_FILE = `${process.env.HOME}/testnet-wallet/wallet-a.json`; +const SYNC_CACHE_A = WALLET_A_FILE.replace(/\.json$/, '-sync.json'); +const daemon = new DaemonRPC({ url: DAEMON_URL }); + +async function main() { + const h = await getHeight(daemon); + console.log(`Chain height: ${h}\n`); + + // Load and sync wallet + const walletA = await loadWalletFromFile(WALLET_A_FILE, 'testnet'); + walletA.setDaemon(daemon); + if (existsSync(SYNC_CACHE_A)) { + walletA.loadSyncCache(JSON.parse(await Bun.file(SYNC_CACHE_A).text())); + } + await walletA.syncWithDaemon(); + + const { unlockedBalance } = await walletA.getStorageBalance(); + console.log(`Unlocked balance: ${unlockedBalance}\n`); + + // Build dry-run transfer + const addr = walletA.getLegacyAddress(); + const result = await walletA.transfer( + [{ address: addr, amount: 10_000_000n }], + { priority: 'default', dryRun: true } + ); + + const tx = result.tx; + const prefix = tx.prefix; + const rct = tx.rct; + + console.log(`TX version: ${prefix.version}, rctType: ${rct.type}`); + console.log(`Inputs: ${prefix.vin.length}, Outputs: ${prefix.vout.length}`); + console.log(`Fee: ${rct.fee}\n`); + + // === 1. VERIFY BALANCE EQUATION === + console.log('=== 1. BALANCE EQUATION ==='); + // sum(pseudoOuts) should equal sum(outPk) + fee*H + p_r + + const H_HEX = '8b655970153799af2aeadc9ff1add0ea6c7251d54154cfa92c173a0dd39c1f94'; + const H_POINT = hexToBytes(H_HEX); + + // Fee commitment: fee * H + const feeBytes = bigIntToBytes(rct.fee); + const feeH = scalarMultPoint(feeBytes, H_POINT); + console.log(` feeH: ${bytesToHex(feeH).slice(0,16)}...`); + + // Sum of outPk + let sumOutPk = null; + for (const pk of rct.outPk) { + const pkBytes = typeof pk === 'string' ? hexToBytes(pk) : pk; + sumOutPk = sumOutPk ? pointAddCompressed(sumOutPk, pkBytes) : pkBytes; + } + console.log(` sum(outPk): ${bytesToHex(sumOutPk).slice(0,16)}...`); + + // p_r + const p_r = typeof rct.p_r === 'string' ? hexToBytes(rct.p_r) : rct.p_r; + console.log(` p_r: ${bytesToHex(p_r).slice(0,16)}...`); + + // LHS = sum(outPk) + feeH + p_r + let lhs = pointAddCompressed(sumOutPk, feeH); + lhs = pointAddCompressed(lhs, p_r); + console.log(` LHS (sum(outPk)+feeH+p_r): ${bytesToHex(lhs).slice(0,16)}...`); + + // Sum of pseudoOuts + let sumPseudo = null; + for (const po of rct.pseudoOuts) { + const poBytes = typeof po === 'string' ? hexToBytes(po) : po; + sumPseudo = sumPseudo ? pointAddCompressed(sumPseudo, poBytes) : poBytes; + } + console.log(` RHS (sum(pseudoOuts)): ${bytesToHex(sumPseudo).slice(0,16)}...`); + + if (bytesToHex(lhs) === bytesToHex(sumPseudo)) { + console.log(' BALANCE: OK ✓\n'); + } else { + console.log(' BALANCE: FAIL ✗\n'); + console.log(' LHS:', bytesToHex(lhs)); + console.log(' RHS:', bytesToHex(sumPseudo)); + } + + // === 2. VERIFY CLSAG === + console.log('=== 2. CLSAG SIGNATURE ==='); + const prefixForSerialization = { + ...tx.prefix, + inputs: tx.prefix.vin, + outputs: tx.prefix.vout + }; + const txPrefixHash = getTxPrefixHash(prefixForSerialization); + const rctBaseSerialized = serializeRctBase(tx.rct); + + // Get the BP+ proof for hashing + const bpProof = tx.rct.bulletproofPlus; + const preMLsagHash = getPreMlsagHash(txPrefixHash, rctBaseSerialized, bpProof); + console.log(` preMLsagHash: ${bytesToHex(preMLsagHash).slice(0,16)}...`); + + const meta = tx._meta; + if (rct.CLSAGs && rct.CLSAGs.length > 0) { + for (let i = 0; i < rct.CLSAGs.length; i++) { + const sig = rct.CLSAGs[i]; + const ring = meta.ringData[i].ring.map(k => typeof k === 'string' ? hexToBytes(k) : k); + const ringComms = meta.ringData[i].ringCommitments.map(c => typeof c === 'string' ? hexToBytes(c) : c); + const pseudoOut = typeof rct.pseudoOuts[i] === 'string' ? hexToBytes(rct.pseudoOuts[i]) : rct.pseudoOuts[i]; + const keyImage = typeof meta.keyImages[i] === 'string' ? hexToBytes(meta.keyImages[i]) : meta.keyImages[i]; + + console.log(` CLSAG[${i}]: ring_size=${ring.length}, realIdx=${meta.ringData[i].realIndex}`); + console.log(` keyImage: ${bytesToHex(keyImage).slice(0,16)}...`); + console.log(` pseudoOut: ${bytesToHex(pseudoOut).slice(0,16)}...`); + + try { + const valid = clsagVerify(preMLsagHash, sig, ring, ringComms, pseudoOut); + console.log(` Verify: ${valid ? 'OK ✓' : 'FAIL ✗'}`); + } catch (e) { + console.log(` Verify ERROR: ${e.message}`); + } + } + } + + // === 3. VERIFY BP+ === + console.log('\n=== 3. BULLETPROOFS+ ==='); + if (bpProof) { + // bpProof should contain V (Noble Points) and proof fields { A, A1, B, r1, s1, d1, L, R } + console.log(` bpProof keys: ${Object.keys(bpProof).join(', ')}`); + console.log(` Has V: ${!!bpProof.V}, V.length: ${bpProof.V?.length}`); + console.log(` Has A: ${!!bpProof.A}, Has L: ${!!bpProof.L}`); + try { + // V from WASM is raw bytes — convert to Noble points for verify + const { bytesToPoint: bpBytesToPoint } = await import('../src/bulletproofs_plus.js'); + const vPoints = bpProof.V.map(v => + v?.toBytes ? v : bpBytesToPoint(typeof v === 'string' ? hexToBytes(v) : v) + ); + const bpValid = verifyBulletproofPlus(vPoints, bpProof); + console.log(` Verify: ${bpValid ? 'OK ✓' : 'FAIL ✗'}`); + } catch (e) { + console.log(` Verify ERROR: ${e.message}`); + console.log(` Stack: ${e.stack?.split('\n').slice(0,3).join('\n')}`); + } + } + + // === 4. CHECK RING MEMBER COMMITMENTS === + console.log('\n=== 4. RING MEMBER DETAILS ==='); + if (meta.ringData[0]) { + const rd = meta.ringData[0]; + console.log(` Ring size: ${rd.ring.length}`); + console.log(` Real index: ${rd.realIndex}`); + console.log(` Real key: ${typeof rd.ring[rd.realIndex] === 'string' ? rd.ring[rd.realIndex].slice(0,16) : bytesToHex(rd.ring[rd.realIndex]).slice(0,16)}...`); + console.log(` Real commitment: ${typeof rd.ringCommitments[rd.realIndex] === 'string' ? rd.ringCommitments[rd.realIndex].slice(0,16) : bytesToHex(rd.ringCommitments[rd.realIndex]).slice(0,16)}...`); + } + + // Check that our owned output's commitment matches what getOuts returns + const allOuts = walletA._storage ? await walletA._storage.getOutputs({ isSpent: false }) : []; + const spendable = allOuts.filter(o => typeof o.isSpendable === 'function' ? o.isSpendable(h) : true); + if (spendable.length > 0) { + const ownedOut = spendable[0]; + console.log(`\n Owned output: ki=${ownedOut.keyImage?.slice(0,16)}...`); + console.log(` stored commitment: ${ownedOut.commitment?.slice(0,16) || 'NONE'}...`); + console.log(` stored mask: ${ownedOut.mask?.slice(0,16) || 'NONE'}...`); + console.log(` stored pubKey: ${ownedOut.publicKey?.slice(0,16) || 'NONE'}...`); + console.log(` stored amount: ${ownedOut.amount}`); + console.log(` isCoinbase: ${!ownedOut.mask}`); + + // If coinbase, verify recomputed commitment matches what daemon has + if (!ownedOut.mask && ownedOut.globalIndex != null) { + const IDENTITY_MASK = '0100000000000000000000000000000000000000000000000000000000000000'; + const recomputed = bytesToHex(commit(ownedOut.amount, hexToBytes(IDENTITY_MASK))); + console.log(` recomputed commitment: ${recomputed.slice(0,16)}...`); + + // Also check via zeroCommit + const zcCommit = bytesToHex(zeroCommit(ownedOut.amount)); + console.log(` zeroCommit(amount): ${zcCommit.slice(0,16)}...`); + + // Fetch from daemon + try { + const outsResp = await daemon.getOuts( + [{ amount: 0, index: ownedOut.globalIndex }], + { asset_type: ownedOut.assetType || 'SAL' } + ); + const daemonOuts = outsResp.result?.outs || outsResp.outs || []; + if (daemonOuts[0]) { + console.log(` daemon commitment (mask): ${daemonOuts[0].mask.slice(0,16)}...`); + console.log(` daemon key: ${daemonOuts[0].key.slice(0,16)}...`); + + if (daemonOuts[0].mask === recomputed) { + console.log(` Commitment MATCH ✓`); + } else { + console.log(` Commitment MISMATCH ✗`); + console.log(` ours: ${recomputed}`); + console.log(` daemon: ${daemonOuts[0].mask}`); + } + } + } catch (e) { + console.log(` Failed to fetch from daemon: ${e.message}`); + } + } + } +} + +main().catch(e => { console.error('FATAL:', e); process.exit(1); }); diff --git a/test/validation.test.js b/test/validation.test.js index 11600d3..0cb3fdb 100644 --- a/test/validation.test.js +++ b/test/validation.test.js @@ -121,8 +121,8 @@ describe('Validation Constants', () => { expect(RCT_TYPE_NAMES.Null).toBe(0); expect(RCT_TYPE_NAMES.CLSAG).toBe(5); expect(RCT_TYPE_NAMES.BulletproofPlus).toBe(6); - expect(RCT_TYPE_NAMES.SalviumZero).toBe(7); - expect(RCT_TYPE_NAMES.SalviumOne).toBe(8); + expect(RCT_TYPE_NAMES.SalviumZero).toBe(8); + expect(RCT_TYPE_NAMES.SalviumOne).toBe(9); }); test('AUDIT_HARD_FORKS has audit periods configured', () => {