Fix BP+ proof generation with WASM backend, fix 7 stale test expectations

ensureBytes() silently converted BigInt mask scalars to all-zeros via
  Uint8Array.set(BigInt), causing Bulletproofs+ proofs to use wrong
  commitments. The daemon correctly rejected these transactions. Also parse
  WASM proof results into full field objects and fix test expectations for
  upper-median, RCT type constants, and Pedersen commitment properties.
This commit is contained in:
Matt Hess
2026-02-10 22:32:06 +00:00
parent 530b3ad3c9
commit 9d3e93ebe3
8 changed files with 559 additions and 15 deletions
+12 -1
View File
@@ -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:
+9
View File
@@ -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;
}
+3 -3
View File
@@ -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', () => {
+13 -5
View File
@@ -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 ──────────────────────────────────────────────────────────────
+4 -4
View File
@@ -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', () => {
+299
View File
@@ -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); });
+217
View File
@@ -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); });
+2 -2
View File
@@ -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', () => {