Fix transaction signing, wallet sync, and stake balance bugs

- CLSAG signing mask: scSub(inputMask, pseudoMask) not reverse
  - Wallet-sync: convert spendSecretKey to bytes for key image calc
  - Stake: subtract amountBurnt from change amount
  - Sweep: limit to 60 inputs to stay under tx weight limit
  - Add spentKeyImages tracking to stake function
This commit is contained in:
Matt Hess
2026-02-03 18:45:56 +00:00
parent 949719c2e7
commit 8dd13b6f10
15 changed files with 1370 additions and 283 deletions
+90 -48
View File
@@ -1,15 +1,15 @@
/**
* Bulletproofs+ Range Proof Verification
* Bulletproofs+ Range Proof Generation & Verification
*
* Pure JavaScript implementation using @noble/curves for Ed25519 operations.
* Uses the crypto provider for hash-to-point (Monero's ge_fromfe_frombytes_vartime).
* Based on the Bulletproofs+ paper and Monero/Salvium implementation.
*
* Reference: https://eprint.iacr.org/2020/735.pdf
*/
import { ed25519, ed25519_hasher } from '@noble/curves/ed25519.js';
import { ed25519 } from '@noble/curves/ed25519.js';
import { mod, invert, Field } from '@noble/curves/abstract/modular.js';
import { keccak256 } from './crypto/index.js';
import { keccak256, hashToPoint as cryptoHashToPoint } from './crypto/index.js';
// Ed25519 curve order (L)
const L = BigInt('0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed');
@@ -20,8 +20,14 @@ const Point = ed25519.Point;
// Scalar field for batch operations
const Fn = Field(L);
// Hash-to-curve function (uses elligator2)
const hashToCurve = ed25519_hasher.hashToCurve;
// Fixed H constant from Monero/Salvium (toPoint(cn_fast_hash(G)))
// This is NOT computed at runtime - it's a fixed basepoint
const H_BYTES = new Uint8Array([
0x8b, 0x65, 0x59, 0x70, 0x15, 0x37, 0x99, 0xaf,
0x2a, 0xea, 0xdc, 0x9f, 0xf1, 0xad, 0xd0, 0xea,
0x6c, 0x72, 0x51, 0xd5, 0x41, 0x54, 0xcf, 0xa9,
0x2c, 0x17, 0x3a, 0x0d, 0xd3, 0x9c, 0x1f, 0x94
]);
// Number of bits in range proof
const N = 64;
@@ -41,6 +47,15 @@ const TWO_64_MINUS_1 = (1n << 64n) - 1n;
let generatorsCache = null;
let transcriptInitCache = null;
/**
* Clear cached generators and transcript.
* Call this after switching crypto backends.
*/
export function clearBulletproofCache() {
generatorsCache = null;
transcriptInitCache = null;
}
/**
* Convert a 32-byte Uint8Array to BigInt (little-endian)
*/
@@ -92,13 +107,24 @@ export function hashToScalar(...inputs) {
}
/**
* Hash to point using elligator2 hash-to-curve
* Note: For production, this should match Monero's hash_to_p3 exactly.
* This version uses the standard hash-to-curve for Ed25519.
* Hash to point matching C++ get_exponent / hash_to_p3 exactly.
*
* C++ flow:
* 1. cn_fast_hash(data) -> hash1
* 2. hash_to_p3(hash1) internally does:
* a. cn_fast_hash(hash1) -> hash2 (DOUBLE HASH!)
* b. ge_fromfe_frombytes_vartime(hash2) -> elligator2
* c. ge_mul8 -> cofactor clear
*
* Our WASM hashToPoint does: keccak256(input) -> elligator2 -> *8
* So we need to pre-hash: hashToPoint(keccak256(data)) = keccak256(keccak256(data)) -> elligator2 -> *8
*/
export function hashToPoint(data) {
// hashToCurve automatically clears cofactor
return hashToCurve(data);
export function hashToPointMonero(data) {
// First hash: matches C++'s cn_fast_hash in get_exponent
const hash1 = keccak256(data);
// WASM hashToPoint does second hash internally, matching hash_to_p3
const pointBytes = cryptoHashToPoint(hash1);
return Point.fromBytes(pointBytes);
}
/**
@@ -116,41 +142,46 @@ function encodeVarint(n) {
/**
* Initialize generators Gi and Hi
* Gi[i] = hash_to_p3(H || "bulletproof_plus" || varint(2*i+1))
* Hi[i] = hash_to_p3(H || "bulletproof_plus" || varint(2*i))
*
* C++ formula (from bulletproofs_plus.cc):
* Hi_p3[i] = get_exponent(rct::H, i * 2);
* Gi_p3[i] = get_exponent(rct::H, i * 2 + 1);
*
* Where get_exponent does:
* hashed = H_bytes + "bulletproof_plus" + varint(idx)
* generator = hash_to_p3(cn_fast_hash(hashed))
*
* Note: H is a FIXED constant, NOT computed from hash-to-point.
*/
export function initGenerators(maxMN = MAX_M * N) {
if (generatorsCache && generatorsCache.Gi.length >= maxMN) {
return generatorsCache;
}
// H is the second generator (hash of G)
const G = Point.BASE;
const GBytes = G.toBytes();
const H = hashToPoint(GBytes);
const H = Point.fromBytes(H_BYTES); // Use fixed H constant
const prefix = new TextEncoder().encode('bulletproof_plus');
const HBytes = H.toBytes();
const Gi = [];
const Hi = [];
for (let i = 0; i < maxMN; i++) {
// Hi uses even indices (2*i) - NOTE: Hi uses even, Gi uses odd (matches C++)
const hiVarint = encodeVarint(2 * i);
const hiData = new Uint8Array(H_BYTES.length + prefix.length + hiVarint.length);
hiData.set(H_BYTES);
hiData.set(prefix, H_BYTES.length);
hiData.set(hiVarint, H_BYTES.length + prefix.length);
Hi.push(hashToPointMonero(hiData));
// Gi uses odd indices (2*i + 1)
const giVarint = encodeVarint(2 * i + 1);
const giData = new Uint8Array(HBytes.length + prefix.length + giVarint.length);
giData.set(HBytes);
giData.set(prefix, HBytes.length);
giData.set(giVarint, HBytes.length + prefix.length);
Gi.push(hashToPoint(giData));
// Hi uses even indices (2*i)
const hiVarint = encodeVarint(2 * i);
const hiData = new Uint8Array(HBytes.length + prefix.length + hiVarint.length);
hiData.set(HBytes);
hiData.set(prefix, HBytes.length);
hiData.set(hiVarint, HBytes.length + prefix.length);
Hi.push(hashToPoint(hiData));
const giData = new Uint8Array(H_BYTES.length + prefix.length + giVarint.length);
giData.set(H_BYTES);
giData.set(prefix, H_BYTES.length);
giData.set(giVarint, H_BYTES.length + prefix.length);
Gi.push(hashToPointMonero(giData));
}
generatorsCache = { G, H, Gi, Hi };
@@ -159,7 +190,15 @@ export function initGenerators(maxMN = MAX_M * N) {
/**
* Initialize transcript with domain separator
* Matches Salvium: hash_to_p3(hash(domain_separator)).toBytes()
*
* C++ formula:
* cn_fast_hash(domain_separator) -> hash1
* hash_to_p3(hash1) internally does:
* cn_fast_hash(hash1) -> hash2
* ge_fromfe_frombytes_vartime(hash2) -> elligator2
* ge_mul8 -> *8
*
* Total: keccak256(keccak256(domain)) -> elligator2 -> *8
*/
export function initTranscript() {
if (transcriptInitCache) {
@@ -167,10 +206,11 @@ export function initTranscript() {
}
const domain = new TextEncoder().encode('bulletproof_plus_transcript');
const domainHash = keccak256(domain);
// Convert hash to point using hash-to-curve, then encode as bytes
const point = hashToPoint(domainHash);
transcriptInitCache = point.toBytes();
// First hash (matches C++ cn_fast_hash)
const hash1 = keccak256(domain);
// WASM hashToPoint does second hash internally
const pointBytes = cryptoHashToPoint(hash1);
transcriptInitCache = pointBytes;
return transcriptInitCache;
}
@@ -192,25 +232,27 @@ function hashKeysToScalar(keys) {
/**
* Update transcript with one element (Salvium style)
* Matches Salvium: hash_to_scalar(transcript || element)
* Matches Salvium: hash_to_scalar(transcript || element) - returns REDUCED scalar bytes
*/
function transcriptUpdate(transcript, element) {
const data = new Uint8Array(64);
data.set(transcript);
data.set(element, 32);
return keccak256(data);
// IMPORTANT: C++ hash_to_scalar reduces the result - we must return reduced bytes
return scalarToBytes(bytesToScalar(keccak256(data)));
}
/**
* Update transcript with two elements (Salvium style)
* Matches Salvium: hash_to_scalar(transcript || element1 || element2)
* Matches Salvium: hash_to_scalar(transcript || element1 || element2) - returns REDUCED scalar bytes
*/
function transcriptUpdate2(transcript, element1, element2) {
const data = new Uint8Array(96);
data.set(transcript);
data.set(element1, 32);
data.set(element2, 64);
return keccak256(data);
// IMPORTANT: C++ hash_to_scalar reduces the result - we must return reduced bytes
return scalarToBytes(bytesToScalar(keccak256(data)));
}
/**
@@ -436,8 +478,10 @@ export function verifyBulletproofPlusBatch(proofs) {
proofTranscript = transcriptUpdate(proofTranscript, A.toBytes());
const y = bytesToScalar(proofTranscript);
// Challenge z from y: z = hash_to_scalar(y) (NOT from transcript!)
const z = bytesToScalar(keccak256(proofTranscript));
// Challenge z from y: z = hash_to_scalar(y)
// IMPORTANT: C++ uses the REDUCED y bytes, not the raw transcript hash
const yBytes = scalarToBytes(y);
const z = bytesToScalar(keccak256(yBytes));
proofTranscript = scalarToBytes(z); // transcript = z
// Challenges for each round: challenge[j] = hash_to_scalar(transcript || L[j] || R[j])
@@ -825,7 +869,9 @@ export function bulletproofPlusProve(amounts, masks) {
}
// Challenge z = hash_to_scalar(y), transcript = z
let z = bytesToScalar(keccak256(transcript));
// IMPORTANT: C++ uses the REDUCED y bytes, not the raw transcript hash
const yBytes = scalarToBytes(y);
let z = bytesToScalar(keccak256(yBytes));
if (z === 0n) {
throw new Error('Challenge z is zero - retry');
}
@@ -1036,18 +1082,14 @@ export function serializeProof(proof) {
const { V, A, A1, B, r1, s1, d1, L, R } = proof;
// Monero/Salvium binary format for BulletproofPlus:
// varint(V.length), V[0..n] (32 bytes each)
// A (32), A1 (32), B (32)
// r1 (32), s1 (32), d1 (32)
// varint(L.length), L[0..n] (32 bytes each)
// varint(R.length), R[0..n] (32 bytes each)
// NOTE: V (commitments) are NOT serialized — they're restored via outPk
const chunks = [];
// V (commitments)
chunks.push(_encodeVarint(V.length));
for (const v of V) chunks.push(v.toBytes());
// A, A1, B (points)
chunks.push(A.toBytes());
chunks.push(A1.toBytes());
+64
View File
@@ -243,6 +243,12 @@ export const STAGENET_CONFIG = {
* @returns {Object} Network configuration
*/
export function getNetworkConfig(network) {
// Accept string names (e.g. 'testnet') as well as numeric NETWORK_ID values
if (typeof network === 'string') {
const NAME_MAP = { mainnet: NETWORK_ID.MAINNET, testnet: NETWORK_ID.TESTNET, stagenet: NETWORK_ID.STAGENET, fakechain: NETWORK_ID.FAKECHAIN };
network = NAME_MAP[network.toLowerCase()];
if (network === undefined) throw new Error(`Unknown network name: ${network}`);
}
switch (network) {
case NETWORK_ID.MAINNET:
case NETWORK_ID.FAKECHAIN:
@@ -296,6 +302,22 @@ export function isHfActive(hfVersion, height, network = NETWORK_ID.MAINNET) {
return getHfVersionForHeight(height, network) >= hfVersion;
}
/**
* Get the active asset type at a given height.
* After the AUDIT1 hard fork (HF 6), SAL transitions to SAL1.
*
* Reference: Salvium wallet2.cpp — uses HF_VERSION_SALVIUM_ONE_PROOFS (6)
*
* @param {number} height - Block height
* @param {number} network - Network type
* @returns {string} 'SAL' or 'SAL1'
*/
export function getActiveAssetType(height, network = NETWORK_ID.MAINNET) {
// At HF6+ (HF_VERSION_SALVIUM_ONE_PROOFS), all TX asset types must be 'SAL1'.
// Coinbase outputs after HF6 also use 'SAL1'. See blockchain.cpp:3845-3846.
return isHfActive(6, height, network) ? 'SAL1' : 'SAL';
}
/**
* Check if CARROT outputs are enabled at a given height
*
@@ -307,6 +329,48 @@ export function isCarrotActive(height, network = NETWORK_ID.MAINNET) {
return isHfActive(HF_VERSION.CARROT, height, network);
}
/**
* Get the correct TX version for a given TX type and height.
*
* Reference: Salvium cryptonote_tx_utils.cpp lines 748-755
* - TRANSFER at HF >= ENABLE_N_OUTS (2): version 3 (TRANSACTION_VERSION_N_OUTS)
* - HF >= CARROT (10): version 4 (TRANSACTION_VERSION_CARROT)
* - Otherwise: version 2
*
* @param {number} txType - Transaction type (from TX_TYPE enum)
* @param {number} height - Block height
* @param {number|string} network - Network type
* @returns {number} TX version (2, 3, or 4)
*/
export function getTxVersion(txType, height, network = NETWORK_ID.MAINNET) {
const hf = getHfVersionForHeight(height, network);
// TX_TYPE.TRANSFER = 3 (from constants)
if (hf >= HF_VERSION.CARROT) return 4;
if (hf >= HF_VERSION.ENABLE_N_OUTS && txType === 3) return 3;
return 2;
}
/**
* Get the required RCT signature type for a given height.
*
* Reference: Salvium blockchain.cpp lines 3737-3774
* - HF >= CARROT (10): RCTTypeSalviumOne (9) — uses TCLSAG
* - HF >= SALVIUM_ONE_PROOFS (6): RCTTypeSalviumZero (8) — uses CLSAG + salvium_data
* - HF >= FULL_PROOFS (3): RCTTypeFullProofs (7) — uses CLSAG + partial salvium_data
* - HF >= 1: RCTTypeBulletproofPlus (6) — standard CLSAG
*
* @param {number} height - Block height
* @param {number|string} network - Network type
* @returns {number} RCT type constant
*/
export function getRctType(height, network = NETWORK_ID.MAINNET) {
const hf = getHfVersionForHeight(height, network);
if (hf >= HF_VERSION.CARROT) return 9; // SalviumOne
if (hf >= HF_VERSION.SALVIUM_ONE_PROOFS) return 8; // SalviumZero
if (hf >= HF_VERSION.FULL_PROOFS) return 7; // FullProofs
return 6; // BulletproofPlus
}
// =============================================================================
// BLOCK REWARD CALCULATION
// =============================================================================
+20 -2
View File
@@ -103,8 +103,26 @@ export class WasmCryptoBackend {
// Hash-to-point & key derivation
hashToPoint(data) { return this.wasm.hash_to_point(data); }
generateKeyImage(pubKey, secKey) { return this.wasm.generate_key_image(pubKey, secKey); }
generateKeyDerivation(pubKey, secKey) { return this.wasm.generate_key_derivation(pubKey, secKey); }
generateKeyImage(pubKey, secKey) {
// Normalize inputs: convert hex strings to Uint8Array
if (typeof pubKey === 'string') {
pubKey = new Uint8Array(pubKey.match(/.{1,2}/g).map(b => parseInt(b, 16)));
}
if (typeof secKey === 'string') {
secKey = new Uint8Array(secKey.match(/.{1,2}/g).map(b => parseInt(b, 16)));
}
return this.wasm.generate_key_image(pubKey, secKey);
}
generateKeyDerivation(pubKey, secKey) {
// Normalize inputs: convert hex strings to Uint8Array
if (typeof pubKey === 'string') {
pubKey = new Uint8Array(pubKey.match(/.{1,2}/g).map(b => parseInt(b, 16)));
}
if (typeof secKey === 'string') {
secKey = new Uint8Array(secKey.match(/.{1,2}/g).map(b => parseInt(b, 16)));
}
return this.wasm.generate_key_derivation(pubKey, secKey);
}
derivePublicKey(derivation, outputIndex, basePub) { return this.wasm.derive_public_key(derivation, outputIndex, basePub); }
deriveSecretKey(derivation, outputIndex, baseSec) { return this.wasm.derive_secret_key(derivation, outputIndex, baseSec); }
+4 -1
View File
@@ -384,7 +384,9 @@ export class DaemonRPC extends RPCClient {
async sendRawTransaction(txAsHex, options = {}) {
return this.post('/send_raw_transaction', {
tx_as_hex: txAsHex,
do_not_relay: options.do_not_relay || false
source_asset_type: options.source_asset_type || '',
do_not_relay: options.do_not_relay || false,
do_sanity_checks: options.do_sanity_checks ?? true
});
}
@@ -951,6 +953,7 @@ export class DaemonRPC extends RPCClient {
success: true,
data: {
o_indexes: data.o_indexes || [],
asset_type_output_indices: data.asset_type_output_indices || [],
status: data.status || 'OK'
}
};
+508 -125
View File
@@ -14,11 +14,13 @@ import {
keccak256, keccak256Hex, scalarMultBase, scalarMultPoint, pointAddCompressed,
getGeneratorG, getGeneratorT,
generateKeyDerivation, derivePublicKey, deriveSecretKey,
derivationToScalar,
derivationToScalar, deriveViewTag,
hashToPoint, generateKeyImage,
} from './crypto/index.js';
import { bytesToHex, hexToBytes } from './address.js';
import { bulletproofPlusProve, serializeProof as serializeBpPlus } from './bulletproofs_plus.js';
import { getTxVersion, getRctType } from './consensus.js';
import { zeroCommit } from './transaction/serialization.js';
// =============================================================================
// RE-EXPORTS FROM SUBMODULES
@@ -107,7 +109,7 @@ import {
import {
bytesToBigInt as _bytesToBigInt, bigIntToBytes as _bigIntToBytes,
scReduce32 as _scReduce32,
scAdd as _scAdd, scSub as _scSub, scMul as _scMul, scRandom as _scRandom,
scAdd as _scAdd, scSub as _scSub, scMul as _scMul, scMulAdd as _scMulAdd, scRandom as _scRandom,
commit as _commit, genCommitmentMask as _genCommitmentMask,
serializeTxPrefix as _serializeTxPrefix, getTxPrefixHash as _getTxPrefixHash,
serializeRctBase as _serializeRctBase
@@ -134,6 +136,7 @@ const scReduce32 = _scReduce32;
const scAdd = _scAdd;
const scSub = _scSub;
const scMul = _scMul;
const scMulAdd = _scMulAdd;
const scRandom = _scRandom;
const commit = _commit;
const genCommitmentMask = _genCommitmentMask;
@@ -232,13 +235,17 @@ export function createOutput(txSecretKey, viewPublicKey, spendPublicKey, amount,
const amountKey = deriveAmountKey(scalar);
const encryptedAmount = encryptAmount(amount, amountKey);
// Derive view tag for tagged key outputs
const viewTag = deriveViewTag(derivation, outputIndex);
return {
outputPublicKey,
txPublicKey,
commitment,
encryptedAmount,
mask,
derivation
derivation,
viewTag
};
}
@@ -303,13 +310,96 @@ function hashToScalar(...data) {
return scReduce32(hash);
}
// =============================================================================
// SALVIUM p_r AND pr_proof (RETURN ADDRESS PROOF)
// =============================================================================
/**
* Compute p_r: the blinding factor remainder commitment.
*
* p_r = difference * G where difference = sum(pseudoOut_masks) - sum(output_masks)
*
* Reference: Salvium rctSigs.cpp lines 1816-1818
* sc_sub(difference.bytes, sumpouts.bytes, sumout.bytes);
* genC(rv.p_r, difference, 0); // commitment with amount=0
*
* @param {Array<Uint8Array>} pseudoMasks - Pseudo output mask scalars
* @param {Array<Uint8Array>} outputMasks - Output mask scalars
* @returns {{ p_r: Uint8Array, difference: Uint8Array }} p_r point (32 bytes) and difference scalar
*/
export function computePR(pseudoMasks, outputMasks) {
// Sum pseudo output masks
let sumPseudo = new Uint8Array(32);
for (const m of pseudoMasks) {
const mask = typeof m === 'string' ? hexToBytes(m) : m;
sumPseudo = scAdd(sumPseudo, mask);
}
// Sum output masks
let sumOut = new Uint8Array(32);
for (const m of outputMasks) {
const mask = typeof m === 'string' ? hexToBytes(m) : m;
sumOut = scAdd(sumOut, mask);
}
// difference = sumPseudo - sumOut
const difference = scSub(sumPseudo, sumOut);
// p_r = difference * G (genC with amount=0 is just scalarmultBase)
const p_r = scalarMultBase(difference);
return { p_r, difference };
}
/**
* Generate a Schnorr proof of knowledge of the discrete log of p_r.
*
* Proves: "I know `difference` such that p_r = difference * G"
*
* Reference: Salvium rctSigs.cpp PRProof_Gen (lines 680-701)
* r = random scalar
* R = r * G
* c = H(R || p_r)
* z1 = r + c * difference
* z2 = 0
*
* @param {Uint8Array} difference - The scalar whose DL we're proving
* @param {Uint8Array} p_r - The commitment point (= difference * G)
* @returns {{ R: Uint8Array, z1: Uint8Array, z2: Uint8Array }} zk_proof
*/
export function generatePRProof(difference, p_r) {
// Random nonce
const r = scRandom();
// R = r * G
const R = scalarMultBase(r);
// c = H(R || p_r) — hash two points to scalar
const c = hashToScalar(R, p_r);
// z1 = r + c * difference
const z1 = scMulAdd(c, difference, r);
// z2 = 0 (unused)
const z2 = new Uint8Array(32);
return { R, z1, z2 };
}
/**
* Domain separator for CLSAG
* C++ pads these to 32 bytes (sc_0 then memcpy)
* Reference: rctSigs.cpp lines 1242-1245, 1266-1267
*/
const CLSAG_DOMAIN = new TextEncoder().encode('CLSAG_');
const CLSAG_AGG_0 = new Uint8Array([...CLSAG_DOMAIN, ...new TextEncoder().encode('agg_0')]);
const CLSAG_AGG_1 = new Uint8Array([...CLSAG_DOMAIN, ...new TextEncoder().encode('agg_1')]);
const CLSAG_ROUND = new Uint8Array([...CLSAG_DOMAIN, ...new TextEncoder().encode('round')]);
function padDomain(str) {
const bytes = new Uint8Array(32);
const encoded = new TextEncoder().encode(str);
bytes.set(encoded.slice(0, 32)); // Copy up to 32 bytes, rest stays zero
return bytes;
}
const CLSAG_AGG_0 = padDomain('CLSAG_agg_0');
const CLSAG_AGG_1 = padDomain('CLSAG_agg_1');
const CLSAG_ROUND = padDomain('CLSAG_round');
/**
* Generate a CLSAG signature
@@ -338,27 +428,48 @@ export function clsagSign(message, ring, secretKey, commitments, commitmentMask,
ring = ring.map(k => typeof k === 'string' ? hexToBytes(k) : k);
commitments = commitments.map(c => typeof c === 'string' ? hexToBytes(c) : c);
// Validate ring data
for (let _i = 0; _i < ring.length; _i++) {
if (!ring[_i] || ring[_i].length !== 32) {
throw new Error(`clsagSign: invalid ring member at index ${_i} (${ring[_i]?.length || 'null'})`);
}
}
for (let _i = 0; _i < commitments.length; _i++) {
if (!commitments[_i] || commitments[_i].length !== 32) {
throw new Error(`clsagSign: invalid commitment at index ${_i} (${commitments[_i]?.length || 'null'})`);
}
}
// Compute commitment differences: C_i - C' (should be commitment to 0 for real input)
// C[i] = commitment[i] - pseudoOutputCommitment
const C = commitments.map(c => pointSub(c, pseudoOutputCommitment));
// Compute key image: I = x * H_p(P)
const P_l = ring[secretIndex];
if (!P_l) throw new Error(`clsagSign: ring[${secretIndex}] (P_l) is null/undefined`);
const I = generateKeyImage(P_l, secretKey);
if (!I) throw new Error('clsagSign: generateKeyImage returned null');
// Compute commitment key image: D = z * H_p(P)
// where z = commitmentMask - pseudoOutputMask
const H_P = hashToPoint(P_l);
if (!H_P) throw new Error('clsagSign: hashToPoint returned null');
const D = scalarMultPoint(commitmentMask, H_P);
if (!D) throw new Error('clsagSign: scalarMultPoint returned null for D');
// D_8 = D * (1/8) — stored in signature and used in aggregation hash
// Matches C++ CLSAG_Gen line 278: scalarmultKey(sig.D, D, INV_EIGHT)
const INV_EIGHT_SCALAR = hexToBytes('792fdce229e50661d0da1c7db39dd30700000000000000000000000000000006');
const D_8 = scalarMultPoint(INV_EIGHT_SCALAR, D);
// Compute aggregate coefficients mu_P and mu_C
// mu_P = H_agg(H_agg_domain, P_1, ..., P_n, I, D, C_1, ..., C_n)
// mu_C = H_agg(H_agg_domain_2, P_1, ..., P_n, I, D, C_1, ..., C_n)
// C++ order: [domain, P[0..n-1], C_nonzero[0..n-1], I, D_8, C_offset]
const aggData = [
...ring,
...commitments, // C_nonzero (original commitments, NOT differences)
I,
D,
...C
D_8,
pseudoOutputCommitment // C_offset
];
const mu_P = hashToScalar(CLSAG_AGG_0, ...aggData);
const mu_C = hashToScalar(CLSAG_AGG_1, ...aggData);
@@ -374,10 +485,9 @@ export function clsagSign(message, ring, secretKey, commitments, commitmentMask,
const aH = scalarMultPoint(alpha, H_P);
// Build the base hash data (matches Salvium C++ rctSigs.cpp:305-320)
// c_to_hash = [domain, P[0..n-1], C[0..n-1], C_offset, message, L, R]
// We'll update L and R for each round
// c_to_hash = [domain, P[0..n-1], C_nonzero[0..n-1], C_offset, message, L, R]
const buildChallengeHash = (L, R) => {
return hashToScalar(CLSAG_ROUND, ...ring, ...C, pseudoOutputCommitment, message, L, R);
return hashToScalar(CLSAG_ROUND, ...ring, ...commitments, pseudoOutputCommitment, message, L, R);
};
// Start the ring: first challenge from alpha commitments
@@ -468,7 +578,7 @@ export function clsagSign(message, ring, secretKey, commitments, commitmentMask,
s: s.map(si => bytesToHex(si)),
c1: bytesToHex(c1),
I: bytesToHex(I),
D: bytesToHex(D)
D: bytesToHex(D_8) // Store D*INV_EIGHT as per C++ CLSAG_Gen
};
}
@@ -524,13 +634,14 @@ export function tclsagSign(message, ring, secretKeyX, secretKeyY, commitments, c
const H_P = hashToPoint(P_l);
const D = scalarMultPoint(commitmentMask, H_P);
// Note: For TCLSAG, we use D directly (not D_8) to maintain symmetry
// between the L equation (uses C = z*G) and R equation (uses D = z*Hp)
// This matches CLSAG behavior and ensures the signing equations are consistent
// D_8 = D * (1/8) — stored in signature and used in aggregation hash
// Matches C++ TCLSAG_Gen line 416: scalarmultKey(sig.D, D, INV_EIGHT)
const INV_EIGHT_SCALAR = hexToBytes('792fdce229e50661d0da1c7db39dd30700000000000000000000000000000006');
const D_8 = scalarMultPoint(INV_EIGHT_SCALAR, D);
// Compute aggregate coefficients mu_P and mu_C
// For TCLSAG, aggregation uses C_nonzero (not C differences) plus I, D, C_offset
const aggData = [...ring, ...commitments, I, D, pseudoOutputCommitment];
// For TCLSAG, aggregation uses C_nonzero (not C differences) plus I, D_8, C_offset
const aggData = [...ring, ...commitments, I, D_8, pseudoOutputCommitment];
const mu_P = hashToScalar(CLSAG_AGG_0, ...aggData);
const mu_C = hashToScalar(CLSAG_AGG_1, ...aggData);
@@ -622,8 +733,11 @@ export function tclsagSign(message, ring, secretKeyX, secretKeyY, commitments, c
const c_sum_x = scMul(c, sum_x);
sx[secretIndex] = scSub(a, c_sum_x);
// sy[l] = b (no secret key component for T generator)
sy[secretIndex] = b;
// sy[l] = b - c * (mu_P * y) — matches C++ clsag_sign_y
// For non-CARROT inputs, y=0, so this simplifies to sy = b
const mu_P_y = scMul(mu_P, secretKeyY);
const c_mu_P_y = scMul(c, mu_P_y);
sy[secretIndex] = scSub(b, c_mu_P_y);
// If c1 wasn't captured, compute it now
if (c1 === null) {
@@ -655,7 +769,7 @@ export function tclsagSign(message, ring, secretKeyX, secretKeyY, commitments, c
sy: sy.map(si => bytesToHex(si)),
c1: bytesToHex(c1),
I: bytesToHex(I),
D: bytesToHex(D)
D: bytesToHex(D_8) // Store D*INV_EIGHT as per C++ TCLSAG_Gen
};
}
@@ -794,12 +908,16 @@ export function tclsagVerify(message, sig, ring, commitments, pseudoOutputCommit
// Compute commitment differences: C[i] = C_nonzero[i] - C_offset
const C = commitments.map(c => pointSub(c, pseudoOutputCommitment));
// Note: We use D directly (not D_8) to maintain symmetry with the L equation
// which uses C = z*G. This ensures signing equations are consistent.
// D from signature is D_8 (D * INV_EIGHT). For R computation we need full D.
// D_full = D_8 * 8 = sig.D * 8
// Use doubling 3 times to match C++ scalarmult8
let D_full = pointAddCompressed(D, D); // 2D
D_full = pointAddCompressed(D_full, D_full); // 4D
D_full = pointAddCompressed(D_full, D_full); // 8D
// Compute aggregate coefficients mu_P and mu_C
// mu_P = H_n("CLSAG_agg_0", P[0..n-1], C_nonzero[0..n-1], I, D, C_offset)
// mu_C = H_n("CLSAG_agg_1", P[0..n-1], C_nonzero[0..n-1], I, D, C_offset)
// Aggregation hash uses D_8 (sig.D), matching C++ verification
// mu_P = H_n("CLSAG_agg_0", P[0..n-1], C_nonzero[0..n-1], I, D_8, C_offset)
// mu_C = H_n("CLSAG_agg_1", P[0..n-1], C_nonzero[0..n-1], I, D_8, C_offset)
const aggData = [...ring, ...commitments, I, D, pseudoOutputCommitment];
const mu_P = hashToScalar(CLSAG_AGG_0, ...aggData);
const mu_C = hashToScalar(CLSAG_AGG_1, ...aggData);
@@ -830,10 +948,11 @@ export function tclsagVerify(message, sig, ring, commitments, pseudoOutputCommit
L = pointAddCompressed(L, c_mu_P_Pi);
L = pointAddCompressed(L, c_mu_C_Ci);
// R = sx[i]*H(P[i]) + c*mu_P*I + c*mu_C*D
// R = sx[i]*H(P[i]) + c*mu_P*I + c*mu_C*D_full
// Note: We use D_full (sig.D * 8) here, matching C++ verification
const sxH = scalarMultPoint(sx[i], H_P_i);
const c_mu_P_I = scalarMultPoint(c_mu_P, I);
const c_mu_C_D = scalarMultPoint(c_mu_C, D);
const c_mu_C_D = scalarMultPoint(c_mu_C, D_full);
let R = pointAddCompressed(sxH, c_mu_P_I);
R = pointAddCompressed(R, c_mu_C_D);
@@ -843,7 +962,9 @@ export function tclsagVerify(message, sig, ring, commitments, pseudoOutputCommit
}
// After going around the ring, c should equal c1
return bytesToHex(c) === bytesToHex(c1);
const cHex = bytesToHex(c);
const c1Hex = bytesToHex(c1);
return cHex === c1Hex;
}
// =============================================================================
@@ -852,36 +973,68 @@ export function tclsagVerify(message, sig, ring, commitments, pseudoOutputCommit
/**
* Compute the pre-MLSAG/CLSAG hash (message to sign)
* This is H(txPrefixHash || ss || pseudoOuts)
* Matches C++ get_pre_mlsag_hash: H(message || H(rctSigBase) || H(bp_components))
*
* @param {Uint8Array|string} txPrefixHash - Hash of transaction prefix
* @param {Uint8Array|string} ss - Serialized bulletproof/rangeproof data
* @param {Array<Uint8Array>} pseudoOuts - Pseudo output commitments
* @param {Uint8Array|string} txPrefixHash - Hash of transaction prefix (hashes[0])
* @param {Uint8Array|string} rctBaseSerialized - Full serialized rctSigBase (hashed to get hashes[1])
* @param {Object} bpProof - Bulletproof+ proof object with A, A1, B, r1, s1, d1, L, R fields
* @returns {Uint8Array} 32-byte message hash
*/
export function getPreMlsagHash(txPrefixHash, ss, pseudoOuts) {
export function getPreMlsagHash(txPrefixHash, rctBaseSerialized, bpProof) {
if (typeof txPrefixHash === 'string') txPrefixHash = hexToBytes(txPrefixHash);
if (typeof ss === 'string') ss = hexToBytes(ss);
if (typeof rctBaseSerialized === 'string') rctBaseSerialized = hexToBytes(rctBaseSerialized);
let totalLen = txPrefixHash.length + ss.length;
pseudoOuts = pseudoOuts.map(p => {
if (typeof p === 'string') p = hexToBytes(p);
totalLen += p.length;
return p;
});
// hashes[0] = message (tx prefix hash)
const hash0 = txPrefixHash;
console.log(` [PRE-MLSAG] rctBase len: ${rctBaseSerialized.length}`);
console.log(` [PRE-MLSAG] hash0 (prefixHash): ${[...hash0].map(x=>x.toString(16).padStart(2,'0')).join('').slice(0,32)}...`);
const data = new Uint8Array(totalLen);
let offset = 0;
data.set(txPrefixHash, offset);
offset += txPrefixHash.length;
data.set(ss, offset);
offset += ss.length;
for (const p of pseudoOuts) {
data.set(p, offset);
offset += p.length;
// hashes[1] = H(serialized rctSigBase)
const hash1 = keccak256(rctBaseSerialized);
// hashes[2] = H(bp+ components: A, A1, B, r1, s1, d1, L[], R[])
// Each component is a 32-byte key, concatenated then hashed
const bpChunks = [];
if (bpProof) {
const toBytes = (v) => {
if (typeof v === 'string') return hexToBytes(v);
if (v && typeof v.toBytes === 'function') return v.toBytes();
if (typeof v === 'bigint') {
// Scalar: convert to 32-byte little-endian
const bytes = new Uint8Array(32);
let n = v;
for (let i = 0; i < 32; i++) { bytes[i] = Number(n & 0xFFn); n >>= 8n; }
return bytes;
}
if (v instanceof Uint8Array) return v;
return v;
};
bpChunks.push(toBytes(bpProof.A));
bpChunks.push(toBytes(bpProof.A1));
bpChunks.push(toBytes(bpProof.B));
bpChunks.push(toBytes(bpProof.r1));
bpChunks.push(toBytes(bpProof.s1));
bpChunks.push(toBytes(bpProof.d1));
for (const l of bpProof.L) bpChunks.push(toBytes(l));
for (const r of bpProof.R) bpChunks.push(toBytes(r));
}
let bpDataLen = 0;
for (const c of bpChunks) bpDataLen += c.length;
const bpData = new Uint8Array(bpDataLen);
let off = 0;
for (const c of bpChunks) { bpData.set(c, off); off += c.length; }
const hash2 = keccak256(bpData);
return keccak256(data);
const _hex = b => [...b].map(x=>x.toString(16).padStart(2,'0')).join('');
console.log(` [PRE-MLSAG] hash1 (H(rctBase)): ${_hex(hash1).slice(0,32)}...`);
console.log(` [PRE-MLSAG] bp components: ${bpChunks.length} chunks, ${bpDataLen} bytes`);
console.log(` [PRE-MLSAG] hash2 (H(bp)): ${_hex(hash2).slice(0,32)}...`);
// prehash = H(hashes[0] || hashes[1] || hashes[2])
const combined = new Uint8Array(96);
combined.set(hash0, 0);
combined.set(hash1, 32);
combined.set(hash2, 64);
return keccak256(combined);
}
/**
@@ -1514,7 +1667,13 @@ export function buildTransaction(params, options = {}) {
returnAddress = null,
returnPubkey = null,
protocolTxData = null,
amountSlippageLimit = 0n
amountSlippageLimit = 0n,
// HF-aware options
height = 0,
network = 0,
// Wallet keys needed for v3 return_address_list F-point computation
viewSecretKey = null,
spendSecretKey = null
} = options;
if (!inputs || inputs.length === 0) {
@@ -1546,10 +1705,10 @@ export function buildTransaction(params, options = {}) {
totalOutputAmount += amount;
}
// Verify balance
const changeAmount = totalInputAmount - totalOutputAmount - feeBig;
// Verify balance (amountBurnt covers STAKE/BURN/CONVERT amounts)
const changeAmount = totalInputAmount - totalOutputAmount - feeBig - amountBurnt;
if (changeAmount < 0n) {
throw new Error(`Insufficient funds: inputs=${totalInputAmount}, outputs=${totalOutputAmount}, fee=${feeBig}`);
throw new Error(`Insufficient funds: inputs=${totalInputAmount}, outputs=${totalOutputAmount}, fee=${feeBig}, burnt=${amountBurnt}`);
}
// Generate transaction secret key
@@ -1580,7 +1739,8 @@ export function buildTransaction(params, options = {}) {
publicKey: output.outputPublicKey,
commitment: output.commitment,
encryptedAmount: output.encryptedAmount,
mask: output.mask
mask: output.mask,
viewTag: output.viewTag
});
outputMasks.push(output.mask);
outputIndex++;
@@ -1603,6 +1763,7 @@ export function buildTransaction(params, options = {}) {
commitment: changeOutput.commitment,
encryptedAmount: changeOutput.encryptedAmount,
mask: changeOutput.mask,
viewTag: changeOutput.viewTag,
isChange: true
});
outputMasks.push(changeOutput.mask);
@@ -1613,24 +1774,85 @@ export function buildTransaction(params, options = {}) {
return generateKeyImage(input.publicKey, input.secretKey);
});
// Determine TX version and RCT type based on hard fork
const txVersion = height > 0 ? getTxVersion(txType, height, network) : TX_VERSION.V2;
const rctType = height > 0 ? getRctType(height, network) : RCT_TYPE.BulletproofPlus;
// Sort outputs by public key (lexicographic) for CARROT (HF10+)
// C++ sorts enotes by K_o: output_set_finalization.cpp:311-314
if (rctType >= 9) {
// Build sort permutation
const indices = outputs.map((_, i) => i);
indices.sort((a, b) => {
const ka = typeof outputs[a].publicKey === 'string' ? outputs[a].publicKey : bytesToHex(outputs[a].publicKey);
const kb = typeof outputs[b].publicKey === 'string' ? outputs[b].publicKey : bytesToHex(outputs[b].publicKey);
// memcmp comparison (byte-by-byte, big endian in hex)
return ka < kb ? -1 : ka > kb ? 1 : 0;
});
const sortedOutputs = indices.map(i => outputs[i]);
const sortedMasks = indices.map(i => outputMasks[i]);
outputs.length = 0;
outputMasks.length = 0;
for (let i = 0; i < sortedOutputs.length; i++) {
outputs.push(sortedOutputs[i]);
outputMasks.push(sortedMasks[i]);
}
}
// Sort inputs by key image (descending memcmp) — C++ cryptonote_tx_utils.cpp:937-946
{
const insOrder = inputs.map((_, i) => i);
insOrder.sort((a, b) => {
const ka = bytesToHex(keyImages[a]);
const kb = bytesToHex(keyImages[b]);
// descending: memcmp > 0 means a comes first
return ka > kb ? -1 : ka < kb ? 1 : 0;
});
const sortedInputs = insOrder.map(i => inputs[i]);
const sortedKeyImages = insOrder.map(i => keyImages[i]);
inputs.length = 0;
keyImages.length = 0;
for (let i = 0; i < sortedInputs.length; i++) {
inputs.push(sortedInputs[i]);
keyImages.push(sortedKeyImages[i]);
}
}
console.log(` [DEBUG buildTx] height=${height}, network=${network}, txType=${txType}, txVersion=${txVersion}, rctType=${rctType}`);
// Build transaction prefix
const txPrefix = {
version: TX_VERSION.RCT_2,
version: txVersion,
unlockTime,
vin: inputs.map((input, i) => ({
type: TXIN_TYPE.KEY,
amount: 0n, // RingCT: always 0
keyOffsets: indicesToOffsets(input.ring.map((_, j) => {
// Convert ring to global indices
return input.ringIndices ? input.ringIndices[j] : j;
})),
assetType: sourceAssetType,
keyOffsets: indicesToOffsets(input.ringIndices
? input.ringIndices.slice(0, input.ring.length)
: input.ring.map((_, j) => j)),
keyImage: keyImages[i]
})),
vout: outputs.map(output => ({
type: TXOUT_TYPE.KEY,
amount: 0n, // RingCT: always 0
target: output.publicKey // 'target' for serialization compatibility
})),
vout: outputs.map(output => {
if (rctType === 9) {
// CARROT v1 output for SalviumOne (HF10+)
return {
type: TXOUT_TYPE.CARROT_V1,
amount: 0n,
target: output.publicKey,
assetType: destinationAssetType,
carrotViewTag: output.carrotViewTag || new Uint8Array(3),
encryptedJanusAnchor: output.encryptedJanusAnchor || new Uint8Array(16)
};
}
return {
type: TXOUT_TYPE.TAGGED_KEY,
amount: 0n,
target: output.publicKey,
assetType: destinationAssetType,
unlockTime: 0n,
viewTag: output.viewTag
};
}),
extra: {
txPubKey: getTxPublicKey(txSecretKey)
},
@@ -1639,12 +1861,25 @@ export function buildTransaction(params, options = {}) {
amount_burnt: amountBurnt,
source_asset_type: sourceAssetType,
destination_asset_type: destinationAssetType,
return_address: returnAddress,
return_pubkey: returnPubkey,
protocol_tx_data: protocolTxData,
amount_slippage_limit: amountSlippageLimit
};
// Set return address fields based on TX version
if (txVersion >= 3 && txType === TX_TYPE.TRANSFER) {
// v3+: return_address_list (F-points) + return_address_change_mask
// For now, use null F-points — full implementation requires wallet secret keys
// to compute F = (y^-1) * a * P_change for each output
const fPoints = outputs.map(() => new Uint8Array(32)); // placeholder zeros
const changeMask = outputs.map(() => 0); // placeholder zeros
txPrefix.return_address_list = fPoints;
txPrefix.return_address_change_mask = new Uint8Array(changeMask);
} else if (txVersion >= 4 && txType === TX_TYPE.STAKE) {
txPrefix.protocol_tx_data = protocolTxData;
} else if (txType !== TX_TYPE.MINER && txType !== TX_TYPE.UNSET && txType !== TX_TYPE.PROTOCOL) {
txPrefix.return_address = returnAddress;
txPrefix.return_pubkey = returnPubkey;
}
// Calculate transaction prefix hash
const txPrefixHash = getTxPrefixHash(txPrefix);
@@ -1660,45 +1895,34 @@ export function buildTransaction(params, options = {}) {
const { pseudoOuts, pseudoMasks } = computePseudoOutputs(inputsForPseudo, outputsForPseudo, feeBig);
// Build RingCT base (needed for pre-MLSAG hash)
const rctBase = {
type: RCT_TYPE.BulletproofPlus,
fee: feeBig,
pseudoOuts: pseudoOuts.map(p => bytesToHex(p)),
ecdhInfo: outputs.map(o => bytesToHex(o.encryptedAmount)),
outPk: outputs.map(o => bytesToHex(o.commitment))
};
// Compute p_r for ALL RCT types — Salvium always includes p_r in the sum check.
// Reference: rctSigs.cpp genRctSimple always calls genC(rv.p_r, difference, 0)
// and verRctSemanticsSimple always does addKeys(sumOutpks, rv.p_r, sumOutpks)
const { p_r, difference: prDifference } = computePR(pseudoMasks, outputMasks);
// Serialize RCT base for pre-MLSAG hash
const rctBaseSerialized = serializeRctBase(rctBase);
// Generate pr_proof and salvium_data for FullProofs/SalviumZero/SalviumOne (types 7-9)
let salvium_data = null;
if (rctType >= 7) {
const prProof = generatePRProof(prDifference, p_r);
// Calculate pre-MLSAG hash (message to sign)
const preMLsagHash = getPreMlsagHash(txPrefixHash, rctBaseSerialized, pseudoOuts);
// Sign each input with CLSAG
const clsags = [];
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
// The mask for signing is the difference between pseudo output mask and real mask
// signingMask = pseudoMask - inputMask (so commitment - pseudoOut = 0)
const inputMask = typeof input.mask === 'string' ? hexToBytes(input.mask) : input.mask;
const signingMask = scSub(pseudoMasks[i], inputMask);
const sig = clsagSign(
preMLsagHash,
input.ring,
input.secretKey,
input.ringCommitments,
signingMask,
pseudoOuts[i],
input.realIndex
);
clsags.push(sig);
if (rctType === 8 || rctType === 9) {
// Full salvium_data_t for SalviumZero/SalviumOne
salvium_data = {
salvium_data_type: 0, // SalviumZero
pr_proof: prProof,
sa_proof: { R: new Uint8Array(32), z1: new Uint8Array(32), z2: new Uint8Array(32) } // zeros for SalviumZero
};
} else {
// FullProofs (7): just the two proofs, no type wrapper
salvium_data = {
pr_proof: prProof,
sa_proof: { R: new Uint8Array(32), z1: new Uint8Array(32), z2: new Uint8Array(32) }
};
}
}
// Generate Bulletproofs+ range proofs for outputs
// Generate Bulletproofs+ range proofs BEFORE pre-MLSAG hash
// (C++ genRctSimple generates BP+ first, then hashes components for CLSAG message)
const bpAmounts = outputs.map(o => o.amount);
const bpMasks = outputMasks.map(m => {
const bytes = typeof m === 'string' ? hexToBytes(m) : m;
@@ -1710,18 +1934,162 @@ export function buildTransaction(params, options = {}) {
serialized: serializeBpPlus(bpProof)
};
// Build RingCT base (needed for pre-MLSAG hash)
// Must include ecdhInfo, outPk, p_r, and salvium_data — matches C++ serialize_rctsig_base
const rctBase = {
type: rctType,
fee: feeBig,
ecdhInfo: outputs.map(o => bytesToHex(o.encryptedAmount)),
outPk: outputs.map(o => bytesToHex(o.commitment)),
p_r: p_r,
salvium_data: salvium_data
};
// Debug: dump rctBase hex for comparison
const rctBaseSerialized = serializeRctBase(rctBase);
const _h = b => [...b].map(x=>x.toString(16).padStart(2,'0')).join('');
console.log(` [DEBUG] rctBase hex (first 100 chars): ${_h(rctBaseSerialized).slice(0, 100)}`);
console.log(` [DEBUG] rctBase total bytes: ${rctBaseSerialized.length}`);
// Calculate pre-MLSAG hash: H(prefixHash || H(rctSigBase) || H(bp+ components))
const preMLsagHash = getPreMlsagHash(txPrefixHash, rctBaseSerialized, bpProof);
// Sign each input: CLSAG for types 6-8, TCLSAG for type 9
const clsags = [];
const tclsags = [];
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
const inputMask = typeof input.mask === 'string' ? hexToBytes(input.mask) : input.mask;
// z = inputMask - pseudoMask, so that C[l] = z*G (commitment difference = z*G)
const signingMask = scSub(inputMask, pseudoMasks[i]);
if (rctType === 9) {
// TCLSAG for SalviumOne — y=zero for non-carrot inputs (see tx_builder.cpp:1145)
const secretKeyY = input.secretKeyY || new Uint8Array(32);
// Debug: verify ring contains the expected public key
const expectedPk = input.ring[input.realIndex];
const expectedPkHex = typeof expectedPk === 'string' ? expectedPk : bytesToHex(expectedPk);
const inputPkHex = typeof input.publicKey === 'string' ? input.publicKey : bytesToHex(input.publicKey);
if (expectedPkHex !== inputPkHex) {
console.log(`[TCLSAG DEBUG] WARNING: ring[${input.realIndex}] != input.publicKey`);
console.log(` ring[${input.realIndex}]: ${expectedPkHex.slice(0, 32)}...`);
console.log(` input.publicKey: ${inputPkHex.slice(0, 32)}...`);
}
const sig = tclsagSign(
preMLsagHash,
input.ring,
input.secretKey,
secretKeyY,
input.ringCommitments,
signingMask,
pseudoOuts[i],
input.realIndex
);
// Debug: verify signature key image matches expected
const expectedKI = typeof keyImages[i] === 'string' ? keyImages[i] : bytesToHex(keyImages[i]);
const sigIHex = typeof sig.I === 'string' ? sig.I : bytesToHex(sig.I);
if (sigIHex !== expectedKI) {
console.log(`[TCLSAG DEBUG] WARNING: sig.I != keyImages[${i}]`);
console.log(` sig.I: ${sigIHex.slice(0, 32)}...`);
console.log(` keyImages[${i}]: ${expectedKI.slice(0, 32)}...`);
}
// Debug: self-verify signature
const verifyResult = tclsagVerify(
preMLsagHash,
sig,
input.ring,
input.ringCommitments,
pseudoOuts[i]
);
console.log(`[TCLSAG DEBUG] Self-verify input ${i}: ${verifyResult ? 'PASS' : 'FAIL'}`);
tclsags.push(sig);
} else {
// CLSAG for all other types
const sig = clsagSign(
preMLsagHash,
input.ring,
input.secretKey,
input.ringCommitments,
signingMask,
pseudoOuts[i],
input.realIndex
);
clsags.push(sig);
}
}
// Assemble complete transaction
const rctObj = {
type: rctType,
fee: feeBig,
pseudoOuts: pseudoOuts.map(p => bytesToHex(p)),
ecdhInfo: outputs.map(o => bytesToHex(o.encryptedAmount)),
outPk: outputs.map(o => bytesToHex(o.commitment)),
bulletproofPlus
};
// p_r is always set (computed for all RCT types)
rctObj.p_r = p_r;
if (salvium_data) rctObj.salvium_data = salvium_data;
// Add signatures
if (rctType === 9) {
rctObj.TCLSAGs = tclsags;
} else {
rctObj.CLSAGs = clsags;
}
// === SELF-VERIFICATION (DEBUG) ===
{
const _toBytes = v => typeof v === 'string' ? hexToBytes(v) : v;
const _hex = b => bytesToHex(b instanceof Uint8Array ? b : hexToBytes(b));
// 1. Sum check: sum(pseudoOuts) == sum(outPk) + fee*H + amount_burnt*H + p_r
let sumPseudo = null;
for (const po of pseudoOuts) {
const pt = _toBytes(po);
sumPseudo = sumPseudo ? pointAddCompressed(sumPseudo, pt) : pt;
}
let sumOut = null;
for (const c of outputs.map(o => o.commitment)) {
const pt = _toBytes(c);
sumOut = sumOut ? pointAddCompressed(sumOut, pt) : pt;
}
// Add fee*H
const feeH = zeroCommit(feeBig);
sumOut = pointAddCompressed(sumOut, feeH);
// Add amount_burnt*H
if (amountBurnt > 0n) {
const burntH = zeroCommit(amountBurnt);
sumOut = pointAddCompressed(sumOut, burntH);
}
// Add p_r
sumOut = pointAddCompressed(sumOut, p_r);
const sumMatch = _hex(sumPseudo) === _hex(sumOut);
console.log(` [VERIFY] Sum check: ${sumMatch ? 'PASS' : 'FAIL'}`);
if (!sumMatch) {
console.log(` sumPseudo: ${_hex(sumPseudo)}`);
console.log(` sumOut: ${_hex(sumOut)}`);
}
// 2. PR proof: log info
if (salvium_data?.pr_proof) {
console.log(` [VERIFY] PR proof: present, p_r=${_hex(p_r).slice(0,16)}...`);
}
}
// === END SELF-VERIFICATION ===
const transaction = {
prefix: txPrefix,
rct: {
type: RCT_TYPE.BulletproofPlus,
fee: feeBig,
pseudoOuts: pseudoOuts.map(p => bytesToHex(p)),
ecdhInfo: outputs.map(o => bytesToHex(o.encryptedAmount)),
outPk: outputs.map(o => bytesToHex(o.commitment)),
CLSAGs: clsags,
bulletproofPlus
},
rct: rctObj,
// Additional metadata (not serialized to chain)
_meta: {
txSecretKey: bytesToHex(txSecretKey),
@@ -1763,7 +2131,9 @@ export function buildStakeTransaction(params, options = {}) {
stakeLockPeriod = 21600, // Mainnet default
assetType = 'SAL',
txSecretKey,
useCarrot = false
useCarrot = false,
height = 0,
network = 0
} = options;
if (!inputs || inputs.length === 0) {
@@ -1843,7 +2213,9 @@ export function buildStakeTransaction(params, options = {}) {
returnAddress: returnAddressBytes,
returnPubkey: returnPubkeyBytes,
protocolTxData,
amountSlippageLimit: 0n
amountSlippageLimit: 0n,
height,
network
}
);
}
@@ -2262,7 +2634,8 @@ export function signTransaction(unsignedTx, secrets) {
const inputMask = typeof secret.mask === 'string' ? hexToBytes(secret.mask) : secret.mask;
const pseudoMask = typeof pseudoMasks[i] === 'string' ? hexToBytes(pseudoMasks[i]) : pseudoMasks[i];
const signingMask = scSub(pseudoMask, inputMask);
// z = inputMask - pseudoMask, so that C[l] = z*G (commitment difference = z*G)
const signingMask = scSub(inputMask, pseudoMask);
const sig = clsagSign(
preMLsagHash,
@@ -2305,12 +2678,16 @@ export function signTransaction(unsignedTx, secrets) {
* @returns {Promise<Array<Object>>} Prepared inputs ready for buildTransaction
*/
export async function prepareInputs(ownedOutputs, rpcClient, options = {}) {
const { ringSize = DEFAULT_RING_SIZE, rctOffsets } = options;
const { ringSize = DEFAULT_RING_SIZE, rctOffsets, assetType = 'SAL' } = options;
// When asset_type is provided, use asset-type-local indices for decoy selection
// and getOuts calls (matching C++ wallet2::get_outs behavior).
// The output distribution and getOuts both work in asset-type-local index space.
// Fetch output distribution once (not per-input)
let offsets = rctOffsets;
if (!offsets && rpcClient) {
const distResp = await rpcClient.getOutputDistribution([0], { cumulative: true });
const distResp = await rpcClient.getOutputDistribution([0], { cumulative: true, rct_asset_type: assetType });
const dist = distResp.result?.distributions?.[0] || distResp.distributions?.[0];
offsets = dist?.distribution || [];
}
@@ -2319,19 +2696,24 @@ export async function prepareInputs(ownedOutputs, rpcClient, options = {}) {
for (const output of ownedOutputs) {
// Select decoy indices
// Use asset-type-local index for decoy selection when available
// (C++ wallet2 uses m_asset_type_output_index, not m_global_output_index)
const outputIndex = output.assetTypeIndex != null ? output.assetTypeIndex : output.globalIndex;
// Select decoy indices (in asset-type-local index space)
const decoyIndices = selectDecoys(
offsets,
output.globalIndex,
outputIndex,
ringSize,
new Set([output.globalIndex])
new Set([outputIndex])
);
// Fetch ring member keys and commitments
let ring, ringCommitments;
if (rpcClient) {
const outsResponse = await rpcClient.getOuts(
decoyIndices.map(i => ({ amount: 0, index: i }))
decoyIndices.map(i => ({ amount: 0, index: i })),
{ asset_type: assetType }
);
const outs = outsResponse.result?.outs || outsResponse.outs || [];
@@ -2345,7 +2727,7 @@ export async function prepareInputs(ownedOutputs, rpcClient, options = {}) {
// Find real index in sorted ring
const sortedIndices = [...decoyIndices].sort((a, b) => a - b);
const realIndex = sortedIndices.indexOf(output.globalIndex);
const realIndex = sortedIndices.indexOf(outputIndex);
// Insert real output at correct position
ring[realIndex] = typeof output.publicKey === 'string'
@@ -2361,6 +2743,7 @@ export async function prepareInputs(ownedOutputs, rpcClient, options = {}) {
amount: output.amount,
mask: output.mask,
globalIndex: output.globalIndex,
assetTypeIndex: outputIndex,
ring,
ringCommitments,
ringIndices: sortedIndices,
+6 -3
View File
@@ -128,7 +128,9 @@ export const TXOUT_TYPE = {
ToKey: 0x02,
KEY: 0x02, // Alias
ToTaggedKey: 0x03,
TAGGED_KEY: 0x03 // Alias
TAGGED_KEY: 0x03, // Alias
ToCarrotV1: 0x04,
CARROT_V1: 0x04 // Alias
};
/**
@@ -181,9 +183,10 @@ export const HF_VERSION_ENABLE_ORACLE = 255;
export const FEE_PER_KB = 200000n; // 2 * 10^5 atomic units
/**
* Fee per byte
* Fee per byte (daemon reports 490 as of 2026-02)
* This should ideally be fetched dynamically from the daemon.
*/
export const FEE_PER_BYTE = 30n;
export const FEE_PER_BYTE = 490n;
/**
* Dynamic fee base fee per KB
+176 -15
View File
@@ -298,7 +298,10 @@ export function genCommitmentMask(sharedSecret) {
* @returns {Uint8Array} Encoded varint
*/
export function encodeVarint(value) {
if (typeof value === 'number') value = BigInt(value);
if (typeof value === 'number') {
if (!Number.isInteger(value)) throw new RangeError(`encodeVarint: not an integer: ${value}`);
value = BigInt(value);
}
const bytes = [];
while (value >= 0x80n) {
@@ -387,16 +390,43 @@ export function serializeTxOutput(output) {
// Amount (varint, usually 0 for RingCT)
chunks.push(encodeVarint(output.amount || 0n));
// Output type + target
if (output.viewTag !== undefined) {
if (output.type === TXOUT_TYPE.ToCarrotV1) {
// CARROT v1 output (HF10+)
chunks.push(new Uint8Array([TXOUT_TYPE.ToCarrotV1]));
// key (32 bytes)
chunks.push(typeof output.target === 'string' ? hexToBytes(output.target) : output.target);
// asset_type (length-prefixed string)
const assetBytes = new TextEncoder().encode(output.assetType || 'SAL1');
chunks.push(encodeVarint(assetBytes.length));
chunks.push(assetBytes);
// view_tag (3 bytes)
const vt = output.carrotViewTag || new Uint8Array(3);
chunks.push(typeof vt === 'string' ? hexToBytes(vt) : vt);
// encrypted_janus_anchor (16 bytes)
const anchor = output.encryptedJanusAnchor || new Uint8Array(16);
chunks.push(typeof anchor === 'string' ? hexToBytes(anchor) : anchor);
} else if (output.viewTag !== undefined) {
// Tagged key output (post-view-tag era)
chunks.push(new Uint8Array([TXOUT_TYPE.ToTaggedKey]));
chunks.push(typeof output.target === 'string' ? hexToBytes(output.target) : output.target);
// asset_type (length-prefixed string)
const assetBytes = new TextEncoder().encode(output.assetType || 'SAL');
chunks.push(encodeVarint(assetBytes.length));
chunks.push(assetBytes);
// unlock_time (varint)
chunks.push(encodeVarint(output.unlockTime || 0n));
// view_tag (1 byte)
chunks.push(new Uint8Array([output.viewTag & 0xff]));
} else {
// Regular key output
// Regular key output (txout_to_key)
chunks.push(new Uint8Array([TXOUT_TYPE.ToKey]));
chunks.push(typeof output.target === 'string' ? hexToBytes(output.target) : output.target);
// asset_type (length-prefixed string)
const assetBytes = new TextEncoder().encode(output.assetType || 'SAL');
chunks.push(encodeVarint(assetBytes.length));
chunks.push(assetBytes);
// unlock_time (varint)
chunks.push(encodeVarint(output.unlockTime || 0n));
}
return concatBytes(chunks);
@@ -424,6 +454,11 @@ export function serializeTxInput(input) {
// Amount (varint)
chunks.push(encodeVarint(input.amount || 0n));
// asset_type (length-prefixed string) — Salvium-specific field
const assetBytes = new TextEncoder().encode(input.assetType || 'SAL');
chunks.push(encodeVarint(assetBytes.length));
chunks.push(assetBytes);
// Key offsets (varint count + varint values)
chunks.push(encodeVarint(input.keyOffsets.length));
for (const offset of input.keyOffsets) {
@@ -671,6 +706,100 @@ export function serializeCLSAG(sig) {
return concatBytes(chunks);
}
/**
* Serialize a TCLSAG signature (used for SalviumOne / HF10+)
*
* TCLSAG has sx[], sy[], c1, D (vs CLSAG which has s[], c1, D)
* Reference: Salvium rctTypes.h lines 560-600
*
* @param {Object} sig - TCLSAG signature { sx: Array, sy: Array, c1, D }
* @returns {Uint8Array} Serialized TCLSAG
*/
export function serializeTCLSAG(sig) {
const chunks = [];
// sx values (no length prefix, determined by ring size)
for (const s of sig.sx) {
chunks.push(typeof s === 'string' ? hexToBytes(s) : s);
}
// sy values (same count as sx)
for (const s of sig.sy) {
chunks.push(typeof s === 'string' ? hexToBytes(s) : s);
}
// c1
chunks.push(typeof sig.c1 === 'string' ? hexToBytes(sig.c1) : sig.c1);
// D (commitment key image)
chunks.push(typeof sig.D === 'string' ? hexToBytes(sig.D) : sig.D);
return concatBytes(chunks);
}
/**
* Serialize a zk_proof (Schnorr proof: R + z1 + z2 = 96 bytes)
*
* @param {Object} proof - { R: Uint8Array, z1: Uint8Array, z2: Uint8Array }
* @returns {Uint8Array} 96-byte serialized proof
*/
export function serializeZkProof(proof) {
const chunks = [];
chunks.push(typeof proof.R === 'string' ? hexToBytes(proof.R) : proof.R);
chunks.push(typeof proof.z1 === 'string' ? hexToBytes(proof.z1) : proof.z1);
chunks.push(typeof proof.z2 === 'string' ? hexToBytes(proof.z2) : proof.z2);
return concatBytes(chunks);
}
/**
* Serialize salvium_data_t for SalviumZero/SalviumOne RCT types.
*
* Reference: Salvium rctTypes.h lines 400-412
*
* @param {Object} data - salvium_data object
* @param {number} data.salvium_data_type - 0=SalviumZero, 1=SalviumZeroAudit, 2=SalviumOne
* @param {Object} data.pr_proof - { R, z1, z2 }
* @param {Object} data.sa_proof - { R, z1, z2 }
* @returns {Uint8Array} Serialized salvium_data
*/
export function serializeSalviumData(data) {
const chunks = [];
// salvium_data_type (varint)
chunks.push(encodeVarint(data.salvium_data_type || 0));
// pr_proof (96 bytes)
chunks.push(serializeZkProof(data.pr_proof || { R: new Uint8Array(32), z1: new Uint8Array(32), z2: new Uint8Array(32) }));
// sa_proof (96 bytes)
chunks.push(serializeZkProof(data.sa_proof || { R: new Uint8Array(32), z1: new Uint8Array(32), z2: new Uint8Array(32) }));
// SalviumZeroAudit-specific fields
if (data.salvium_data_type === 1) {
// cz_proof (96 bytes)
chunks.push(serializeZkProof(data.cz_proof || { R: new Uint8Array(32), z1: new Uint8Array(32), z2: new Uint8Array(32) }));
// input_verification_data (vector)
const ivd = data.input_verification_data || [];
chunks.push(encodeVarint(ivd.length));
for (const item of ivd) {
chunks.push(typeof item === 'string' ? hexToBytes(item) : item);
}
// spend_pubkey (32 bytes)
const spk = data.spend_pubkey || new Uint8Array(32);
chunks.push(typeof spk === 'string' ? hexToBytes(spk) : spk);
// enc_view_privkey_str (length-prefixed string)
const evpStr = data.enc_view_privkey_str || '';
const evpBytes = new TextEncoder().encode(evpStr);
chunks.push(encodeVarint(evpBytes.length));
if (evpBytes.length > 0) chunks.push(evpBytes);
}
return concatBytes(chunks);
}
/**
* Serialize RingCT base (type + fee)
*
@@ -690,6 +819,36 @@ export function serializeRctBase(rct) {
chunks.push(encodeVarint(rct.fee || 0n));
}
// ecdhInfo (8 bytes per output — compact format for BP+ types)
if (rct.ecdhInfo) {
chunks.push(serializeEcdhInfo(rct.ecdhInfo));
}
// outPk (32 bytes per output)
if (rct.outPk) {
chunks.push(serializeOutPk(rct.outPk));
}
// p_r (32 bytes) — always present in Salvium
const pR = rct.p_r
? (typeof rct.p_r === 'string' ? hexToBytes(rct.p_r) : rct.p_r)
: new Uint8Array(32);
chunks.push(pR);
// salvium_data — depends on RCT type
const rctType = rct.type;
if (rctType === 8 || rctType === 9) {
chunks.push(serializeSalviumData(rct.salvium_data || {
salvium_data_type: 0,
pr_proof: { R: new Uint8Array(32), z1: new Uint8Array(32), z2: new Uint8Array(32) },
sa_proof: { R: new Uint8Array(32), z1: new Uint8Array(32), z2: new Uint8Array(32) }
}));
} else if (rctType === 7) {
const sd = rct.salvium_data || {};
chunks.push(serializeZkProof(sd.pr_proof || { R: new Uint8Array(32), z1: new Uint8Array(32), z2: new Uint8Array(32) }));
chunks.push(serializeZkProof(sd.sa_proof || { R: new Uint8Array(32), z1: new Uint8Array(32), z2: new Uint8Array(32) }));
}
return concatBytes(chunks);
}
@@ -737,11 +896,9 @@ export function serializeTransaction(tx) {
// Adapt prefix structure for serializeTxPrefix
// (buildTransaction uses vin/vout, serializeTxPrefix expects inputs/outputs)
const prefixForSerialization = {
version: tx.prefix.version,
unlockTime: tx.prefix.unlockTime,
...tx.prefix,
inputs: tx.prefix.vin,
outputs: tx.prefix.vout,
extra: tx.prefix.extra
outputs: tx.prefix.vout
};
const chunks = [];
@@ -749,13 +906,11 @@ export function serializeTransaction(tx) {
// 1. TX prefix
chunks.push(serializeTxPrefix(prefixForSerialization));
// 2. RCT base: type + fee + ecdhInfo + outPk
// 2. RCT base: type + fee + ecdhInfo + outPk + p_r + salvium_data
// (matches Salvium serialize_rctsig_base)
chunks.push(serializeRctBase(tx.rct));
chunks.push(serializeEcdhInfo(tx.rct.ecdhInfo));
chunks.push(serializeOutPk(tx.rct.outPk));
// 3. RCT prunable: BP+ proofs, CLSAGs, pseudoOuts
// 3. RCT prunable: BP+ proofs, signatures, pseudoOuts
// (matches Salvium serialize_rctsig_prunable)
// BP+ proofs (varint count + proof data)
@@ -766,9 +921,15 @@ export function serializeTransaction(tx) {
chunks.push(encodeVarint(0));
}
// CLSAGs
for (const sig of tx.rct.CLSAGs) {
chunks.push(serializeCLSAG(sig));
// Ring signatures: TCLSAG for SalviumOne (9), CLSAG for all others
if (tx.rct.type === 9 && tx.rct.TCLSAGs) {
for (const sig of tx.rct.TCLSAGs) {
chunks.push(serializeTCLSAG(sig));
}
} else if (tx.rct.CLSAGs) {
for (const sig of tx.rct.CLSAGs) {
chunks.push(serializeCLSAG(sig));
}
}
// pseudoOuts (in prunable section for BP+ types)
+5 -2
View File
@@ -26,7 +26,7 @@ export { UTXO_STRATEGY };
* @param {number} options.minConfirmations - Minimum confirmations required (default: 10)
* @param {number} options.currentHeight - Current blockchain height (for confirmation check)
* @param {bigint} options.dustThreshold - Minimum output value to consider (default: 1000000n)
* @param {number} options.maxInputs - Maximum inputs to use (default: 150)
* @param {number} options.maxInputs - Maximum inputs to use (default: 16 for TCLSAG tx size limit)
* @returns {Object} { selected: Array<Object>, totalAmount: bigint, changeAmount: bigint, estimatedFee: bigint }
*/
export function selectUTXOs(utxos, targetAmount, feePerInput, options = {}) {
@@ -35,7 +35,10 @@ export function selectUTXOs(utxos, targetAmount, feePerInput, options = {}) {
minConfirmations = CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE,
currentHeight = 0,
dustThreshold = 1000000n,
maxInputs = 150
// Max inputs limited by daemon's tx weight limit (149400 bytes).
// Each TCLSAG input adds ~2KB (ring 16 × 2 × 32B responses + commitments + offsets).
// With 2 outputs + BP+ proof (~800B) + salvium_data (~200B), safe limit is ~16 inputs.
maxInputs = 16
} = options;
if (typeof targetAmount === 'number') {
+60
View File
@@ -100,6 +100,7 @@ export class WalletOutput {
this.txHash = data.txHash || null; // Transaction hash
this.outputIndex = data.outputIndex || 0; // Output index in transaction
this.globalIndex = data.globalIndex || null; // Global output index
this.assetTypeIndex = data.assetTypeIndex ?? null; // Asset-type-local output index
// Block info
this.blockHeight = data.blockHeight || null; // Block height
@@ -186,6 +187,7 @@ export class WalletOutput {
txHash: this.txHash,
outputIndex: this.outputIndex,
globalIndex: this.globalIndex,
assetTypeIndex: this.assetTypeIndex,
blockHeight: this.blockHeight,
blockTimestamp: this.blockTimestamp,
amount: this.amount.toString(),
@@ -541,6 +543,64 @@ export class MemoryStorage extends WalletStorage {
if (query.txHash && tx.txHash !== query.txHash) return false;
return true;
}
/**
* Dump all storage state to a plain JSON-serializable object.
* Useful for persisting MemoryStorage to a file or any key-value store.
* @returns {Object} Serializable snapshot of all storage data
*/
dump() {
return {
version: 1,
syncHeight: this._syncHeight,
outputs: Array.from(this._outputs.values()).map(o => o.toJSON()),
transactions: Array.from(this._transactions.values()).map(t => t.toJSON()),
spentKeyImages: Array.from(this._spentKeyImages),
blockHashes: Object.fromEntries(this._blockHashes),
state: Object.fromEntries(this._state)
};
}
/**
* Restore storage state from a dump() snapshot.
* @param {Object} data - Previously dumped state
*/
load(data) {
if (!data || data.version !== 1) return;
this._syncHeight = data.syncHeight || 0;
if (data.outputs) {
for (const o of data.outputs) {
const wo = WalletOutput.fromJSON(o);
this._outputs.set(wo.keyImage, wo);
if (wo.keyImage) {
this._keyImages.set(wo.keyImage, { txHash: wo.txHash, outputIndex: wo.outputIndex });
}
}
}
if (data.transactions) {
for (const t of data.transactions) {
const wt = WalletTransaction.fromJSON(t);
this._transactions.set(wt.txHash, wt);
}
}
if (data.spentKeyImages) {
for (const ki of data.spentKeyImages) this._spentKeyImages.add(ki);
}
if (data.blockHashes) {
for (const [h, hash] of Object.entries(data.blockHashes)) {
this._blockHashes.set(parseInt(h), hash);
}
}
if (data.state) {
for (const [k, v] of Object.entries(data.state)) this._state.set(k, v);
}
}
}
// ============================================================================
+6 -2
View File
@@ -968,7 +968,8 @@ export class WalletSync {
unlockTime: tx.prefix?.unlockTime || 0n,
txType,
txPubKey: txPubKey ? bytesToHex(txPubKey) : null,
isCarrot: scanResult.isCarrot || false
isCarrot: scanResult.isCarrot || false,
assetType: output.assetType || 'SAL'
});
await this.storage.putOutput(walletOutput);
@@ -1240,7 +1241,10 @@ export class WalletSync {
if (this.keys.spendSecretKey && this.keys.viewSecretKey) {
try {
// For subaddresses (not main address), compute the subaddress secret key
let baseSpendSecretKey = this.keys.spendSecretKey;
// Always convert to bytes for deriveSecretKey
let baseSpendSecretKey = typeof this.keys.spendSecretKey === 'string'
? hexToBytes(this.keys.spendSecretKey)
: this.keys.spendSecretKey;
if (subaddressIndex.major !== 0 || subaddressIndex.minor !== 0) {
// m = H_s("SubAddr\0" || k_view || major || minor)
const subaddrScalar = cnSubaddressSecretKey(
+126 -34
View File
@@ -12,7 +12,7 @@ import {
buildTransaction, buildStakeTransaction, prepareInputs, estimateTransactionFee,
selectUTXOs, serializeTransaction, TX_TYPE, DEFAULT_RING_SIZE
} from '../transaction.js';
import { getNetworkConfig, NETWORK_ID } from '../consensus.js';
import { getNetworkConfig, NETWORK_ID, getActiveAssetType } from '../consensus.js';
import {
generateKeyDerivation, deriveSecretKey, scalarAdd
} from '../crypto/index.js';
@@ -72,11 +72,15 @@ async function resolveGlobalIndices(outputs, daemon) {
throw new Error(`Failed to get output indexes for tx ${txHash}`);
}
const oIndexes = resp.data.o_indexes;
const assetTypeIndexes = resp.data.asset_type_output_indices || [];
for (const out of outs) {
if (out.outputIndex < oIndexes.length) {
const gi = Number(oIndexes[out.outputIndex]);
indices.set(out.keyImage, gi);
out.globalIndex = gi;
if (out.outputIndex < assetTypeIndexes.length) {
out.assetTypeIndex = Number(assetTypeIndexes[out.outputIndex]);
}
}
}
}
@@ -102,7 +106,7 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
const {
priority = 'default',
subtractFeeFromAmount = false,
assetType = 'SAL',
assetType: assetTypeOpt,
dryRun = false
} = options;
@@ -133,7 +137,8 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
if (!infoResp.success) throw new Error('Failed to get daemon info');
const currentHeight = infoResp.result?.height || infoResp.data?.height;
// 4. Get spendable outputs
// 4. Get spendable outputs — use HF-based asset type if not specified
const assetType = assetTypeOpt || getActiveAssetType(currentHeight, options.network);
const allOutputs = await wallet.storage.getOutputs({
isSpent: false,
isFrozen: false,
@@ -194,7 +199,7 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
const IDENTITY_MASK = '0100000000000000000000000000000000000000000000000000000000000000';
const { commit: pedersenCommit } = await import('../transaction/serialization.js');
const ownedForPrep = selectedOutputs.map(o => {
const ownedForPrep = selectedOutputs.map((o, idx) => {
let mask = o.mask;
let commitment = o.commitment;
@@ -204,19 +209,26 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
commitment = bytesToHex(pedersenCommit(o.amount, hexToBytes(IDENTITY_MASK)));
}
// Debug: check if publicKey looks valid
const pkType = typeof o.publicKey;
const pkLen = o.publicKey?.length;
console.log(`[PREP DEBUG] Output ${idx}: publicKey type=${pkType}, len=${pkLen}, value=${String(o.publicKey).slice(0, 32)}...`);
return {
secretKey: deriveOutputSecretKey(o, wallet.keys),
publicKey: o.publicKey,
amount: o.amount,
mask,
globalIndex: o.globalIndex,
assetTypeIndex: o.assetTypeIndex,
commitment
};
});
// 10. Prepare inputs (fetch decoys from daemon)
const preparedInputs = await prepareInputs(ownedForPrep, daemon, {
ringSize: DEFAULT_RING_SIZE
ringSize: DEFAULT_RING_SIZE,
assetType
});
// 11. Build change address from wallet's primary address
@@ -227,6 +239,10 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
};
// 12. Build the transaction
const spendPub = typeof wallet.keys.spendPublicKey === 'string'
? hexToBytes(wallet.keys.spendPublicKey) : wallet.keys.spendPublicKey;
const viewPub = typeof wallet.keys.viewPublicKey === 'string'
? hexToBytes(wallet.keys.viewPublicKey) : wallet.keys.viewPublicKey;
const tx = buildTransaction(
{
inputs: preparedInputs,
@@ -237,22 +253,43 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
{
txType: TX_TYPE.TRANSFER,
sourceAssetType: assetType,
destinationAssetType: assetType
destinationAssetType: assetType,
returnAddress: spendPub,
returnPubkey: viewPub,
height: currentHeight,
network: options.network
}
);
// DEBUG: log TX structure
console.log(` [DEBUG] TX version: ${tx.prefix.version}, RCT type: ${tx.rct.type}`);
console.log(` [DEBUG] p_r: ${bytesToHex(typeof tx.rct.p_r === 'string' ? hexToBytes(tx.rct.p_r) : tx.rct.p_r).slice(0, 16)}...`);
console.log(` [DEBUG] ecdhInfo[0]: ${tx.rct.ecdhInfo[0]?.slice(0, 16)}...`);
console.log(` [DEBUG] outPk count: ${tx.rct.outPk.length}, pseudoOuts count: ${tx.rct.pseudoOuts.length}`);
console.log(` [DEBUG] CLSAGs: ${tx.rct.CLSAGs?.length || 0}, BP+ proof: ${tx.rct.bulletproofPlus ? 'yes' : 'no'}`);
if (tx.rct.salvium_data) console.log(` [DEBUG] salvium_data: type=${tx.rct.salvium_data.salvium_data_type}`);
// 13. Serialize
const serialized = serializeTransaction(tx);
const txHex = bytesToHex(serialized);
// DEBUG: dump TX hex for analysis and round-trip test
try {
const fs = await import('fs');
fs.writeFileSync('/tmp/last_tx.hex', txHex);
console.log(` [DEBUG] TX hex dumped to /tmp/last_tx.hex (${txHex.length / 2} bytes)`);
} catch (_e) { /* ignore */ }
// 14. Broadcast
if (!dryRun) {
const sendResp = await daemon.sendRawTransaction(txHex);
const sendResp = await daemon.sendRawTransaction(txHex, { source_asset_type: assetType });
if (!sendResp.success) {
throw new Error(`Failed to broadcast: ${JSON.stringify(sendResp.error || sendResp.data)}`);
throw new Error(`Failed to broadcast: ${JSON.stringify(sendResp)}`);
}
if (sendResp.data?.status !== 'OK' && sendResp.data?.result?.status !== 'OK') {
const reason = sendResp.data?.reason || sendResp.data?.result?.reason || 'unknown';
const respData = sendResp.result || sendResp.data?.result || sendResp.data;
console.log(` [DEBUG] Daemon response:`, JSON.stringify(respData));
if (respData?.status !== 'OK') {
const reason = respData?.reason || respData?.status || 'unknown';
throw new Error(`Transaction rejected: ${reason}`);
}
}
@@ -261,13 +298,17 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
const { keccak256 } = await import('../crypto/index.js');
const txHash = bytesToHex(keccak256(serialized));
// Collect spent key images for caller to mark as spent
const spentKeyImages = selection.selected.map(u => u.keyImage);
return {
txHash,
fee: estimatedFee,
tx,
serializedHex: txHex,
inputCount: preparedInputs.length,
outputCount: tx.prefix.vout.length
outputCount: tx.prefix.vout.length,
spentKeyImages
};
}
@@ -287,7 +328,7 @@ export async function transfer({ wallet, daemon, destinations, options = {} }) {
export async function sweep({ wallet, daemon, address, options = {} }) {
const {
priority = 'default',
assetType = 'SAL',
assetType: assetTypeOpt,
dryRun = false
} = options;
@@ -302,18 +343,30 @@ export async function sweep({ wallet, daemon, address, options = {} }) {
if (!infoResp.success) throw new Error('Failed to get daemon info');
const currentHeight = infoResp.result?.height || infoResp.data?.height;
// Use HF-based asset type detection (like C++ wallet2)
const assetType = assetTypeOpt || getActiveAssetType(currentHeight, options.network);
// Get all spendable outputs
const allOutputs = await wallet.storage.getOutputs({
isSpent: false,
isFrozen: false,
assetType
});
const spendable = allOutputs.filter(o => o.isSpendable(currentHeight));
let spendable = allOutputs.filter(o => o.isSpendable(currentHeight));
if (spendable.length === 0) {
throw new Error('No spendable outputs available');
}
// Limit inputs to avoid exceeding max tx weight (149400 bytes)
// Each TCLSAG input adds ~2KB, so max ~60 inputs safely
const MAX_SWEEP_INPUTS = 60;
if (spendable.length > MAX_SWEEP_INPUTS) {
// Sort by amount descending to sweep largest first
spendable.sort((a, b) => b.amount > a.amount ? 1 : b.amount < a.amount ? -1 : 0);
spendable = spendable.slice(0, MAX_SWEEP_INPUTS);
}
let totalAmount = 0n;
for (const o of spendable) totalAmount += o.amount;
@@ -333,21 +386,41 @@ export async function sweep({ wallet, daemon, address, options = {} }) {
await resolveGlobalIndices(spendable, daemon);
// Derive secret keys
const ownedForPrep = spendable.map(o => ({
secretKey: deriveOutputSecretKey(o, wallet.keys),
publicKey: o.publicKey,
amount: o.amount,
mask: o.mask,
globalIndex: o.globalIndex,
commitment: o.commitment
}));
const IDENTITY_MASK = '0100000000000000000000000000000000000000000000000000000000000000';
const { commit: pedersenCommit } = await import('../transaction/serialization.js');
const ownedForPrep = spendable.map(o => {
let mask = o.mask;
let commitment = o.commitment;
// Coinbase outputs have no RCT mask — use identity
if (!mask) {
mask = IDENTITY_MASK;
commitment = bytesToHex(pedersenCommit(o.amount, hexToBytes(IDENTITY_MASK)));
}
return {
secretKey: deriveOutputSecretKey(o, wallet.keys),
publicKey: o.publicKey,
amount: o.amount,
mask,
globalIndex: o.globalIndex,
assetTypeIndex: o.assetTypeIndex,
commitment
};
});
// Prepare inputs
const preparedInputs = await prepareInputs(ownedForPrep, daemon, {
ringSize: DEFAULT_RING_SIZE
ringSize: DEFAULT_RING_SIZE,
assetType
});
// Build TX (no change output)
const sweepSpendPub = typeof wallet.keys.spendPublicKey === 'string'
? hexToBytes(wallet.keys.spendPublicKey) : wallet.keys.spendPublicKey;
const sweepViewPub = typeof wallet.keys.viewPublicKey === 'string'
? hexToBytes(wallet.keys.viewPublicKey) : wallet.keys.viewPublicKey;
const tx = buildTransaction(
{
inputs: preparedInputs,
@@ -363,7 +436,11 @@ export async function sweep({ wallet, daemon, address, options = {} }) {
{
txType: TX_TYPE.TRANSFER,
sourceAssetType: assetType,
destinationAssetType: assetType
destinationAssetType: assetType,
returnAddress: sweepSpendPub,
returnPubkey: sweepViewPub,
height: currentHeight,
network: options.network
}
);
@@ -372,12 +449,14 @@ export async function sweep({ wallet, daemon, address, options = {} }) {
const txHex = bytesToHex(serialized);
if (!dryRun) {
const sendResp = await daemon.sendRawTransaction(txHex);
const sendResp = await daemon.sendRawTransaction(txHex, { source_asset_type: assetType });
if (!sendResp.success) {
throw new Error(`Failed to broadcast: ${JSON.stringify(sendResp.error || sendResp.data)}`);
}
if (sendResp.data?.status !== 'OK' && sendResp.data?.result?.status !== 'OK') {
const reason = sendResp.data?.reason || sendResp.data?.result?.reason || 'unknown';
const respData = sendResp.result || sendResp.data?.result || sendResp.data;
console.log(` [DEBUG] Sweep daemon response:`, JSON.stringify(respData));
if (respData?.status !== 'OK') {
const reason = respData?.reason || respData?.status || 'unknown';
throw new Error(`Transaction rejected: ${reason}`);
}
}
@@ -415,7 +494,7 @@ export async function sweep({ wallet, daemon, address, options = {} }) {
export async function stake({ wallet, daemon, amount, options = {} }) {
const {
priority = 'default',
assetType = 'SAL',
assetType: assetTypeOpt,
network = 'mainnet',
dryRun = false
} = options;
@@ -436,7 +515,10 @@ export async function stake({ wallet, daemon, amount, options = {} }) {
if (!infoResp.success) throw new Error('Failed to get daemon info');
const currentHeight = infoResp.result?.height || infoResp.data?.height;
// 2. Get spendable outputs
// 2. Use HF-based asset type detection (like C++ wallet2)
const assetType = assetTypeOpt || getActiveAssetType(currentHeight, network);
// 3. Get spendable outputs
const allOutputs = await wallet.storage.getOutputs({
isSpent: false,
isFrozen: false,
@@ -503,13 +585,15 @@ export async function stake({ wallet, daemon, amount, options = {} }) {
amount: o.amount,
mask,
globalIndex: o.globalIndex,
assetTypeIndex: o.assetTypeIndex,
commitment
};
});
// 8. Prepare inputs (fetch decoys)
const preparedInputs = await prepareInputs(ownedForPrep, daemon, {
ringSize: DEFAULT_RING_SIZE
ringSize: DEFAULT_RING_SIZE,
assetType
});
// 9. Return address = wallet's own address (stake returns here)
@@ -529,7 +613,9 @@ export async function stake({ wallet, daemon, amount, options = {} }) {
},
{
stakeLockPeriod,
assetType
assetType,
height: currentHeight,
network
}
);
@@ -539,12 +625,14 @@ export async function stake({ wallet, daemon, amount, options = {} }) {
// 12. Broadcast
if (!dryRun) {
const sendResp = await daemon.sendRawTransaction(txHex);
const sendResp = await daemon.sendRawTransaction(txHex, { source_asset_type: assetType });
if (!sendResp.success) {
throw new Error(`Failed to broadcast: ${JSON.stringify(sendResp.error || sendResp.data)}`);
}
if (sendResp.data?.status !== 'OK' && sendResp.data?.result?.status !== 'OK') {
const reason = sendResp.data?.reason || sendResp.data?.result?.reason || 'unknown';
const respData = sendResp.result || sendResp.data?.result || sendResp.data;
console.log(` [DEBUG] Stake daemon response:`, JSON.stringify(respData));
if (respData?.status !== 'OK') {
const reason = respData?.reason || respData?.status || 'unknown';
throw new Error(`Transaction rejected: ${reason}`);
}
}
@@ -553,6 +641,9 @@ export async function stake({ wallet, daemon, amount, options = {} }) {
const { keccak256 } = await import('../crypto/index.js');
const txHash = bytesToHex(keccak256(serialized));
// Collect spent key images for caller to mark as spent
const spentKeyImages = selection.selected.map(u => u.keyImage);
return {
txHash,
fee: estimatedFee,
@@ -561,6 +652,7 @@ export async function stake({ wallet, daemon, amount, options = {} }) {
tx,
serializedHex: txHex,
inputCount: preparedInputs.length,
outputCount: tx.prefix.vout.length
outputCount: tx.prefix.vout.length,
spentKeyImages
};
}
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env bun
/**
* Debug: Build a TX in dry-run mode and dump the serialized hex for analysis.
* Compares our serialization with what the daemon expects.
*/
import { DaemonRPC } from '../src/rpc/daemon.js';
import { Wallet } from '../src/wallet.js';
import { createWalletSync } from '../src/wallet-sync.js';
import { MemoryStorage } from '../src/wallet-store.js';
import { transfer } from '../src/wallet/transfer.js';
import { hexToBytes } from '../src/address.js';
const DAEMON_URL = process.env.DAEMON_URL || 'http://web.whiskymine.io:29081';
const WALLET_FILE = process.env.WALLET_FILE || `${process.env.HOME}/testnet-wallet/wallet.json`;
const NETWORK = 'testnet';
const daemon = new DaemonRPC({ url: DAEMON_URL });
// Load wallet
const walletJson = JSON.parse(await Bun.file(WALLET_FILE).text());
const keys = {
viewSecretKey: walletJson.viewSecretKey,
spendSecretKey: walletJson.spendSecretKey,
viewPublicKey: walletJson.viewPublicKey,
spendPublicKey: walletJson.spendPublicKey,
};
// Sync
const storage = new MemoryStorage();
const sync = createWalletSync({ daemon, keys, storage, network: NETWORK });
await sync.start();
// Create ephemeral wallet B
const walletB = Wallet.create({ network: NETWORK });
const addressB = walletB.getAddress();
// Build TX (dry run)
const result = await transfer({
wallet: { keys, storage },
daemon,
destinations: [{ address: addressB, amount: 10_000_000_000n }],
options: { priority: 'default', dryRun: true, network: NETWORK }
});
const hex = result.serializedHex;
const bytes = hexToBytes(hex);
console.log(`TX hex length: ${hex.length} chars (${bytes.length} bytes)\n`);
// Parse the prefix manually
let pos = 0;
function readVarint() {
let result = 0n;
let shift = 0n;
while (pos < bytes.length) {
const b = bytes[pos++];
result |= BigInt(b & 0x7f) << shift;
if ((b & 0x80) === 0) break;
shift += 7n;
}
return result;
}
function readBytes(n) {
const slice = bytes.slice(pos, pos + n);
pos += n;
return slice;
}
function readString() {
const len = Number(readVarint());
const s = new TextDecoder().decode(readBytes(len));
return s;
}
function toHex(arr) {
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
console.log('=== TX PREFIX ===');
const version = readVarint();
console.log(`version: ${version}`);
const unlockTime = readVarint();
console.log(`unlock_time: ${unlockTime}`);
// Inputs
const numInputs = Number(readVarint());
console.log(`\nvin count: ${numInputs}`);
for (let i = 0; i < numInputs; i++) {
const tag = bytes[pos++];
console.log(` vin[${i}] tag: 0x${tag.toString(16)}`);
if (tag === 0x02) { // txin_to_key
const amount = readVarint();
const assetType = readString();
const numOffsets = Number(readVarint());
const offsets = [];
for (let j = 0; j < numOffsets; j++) offsets.push(Number(readVarint()));
const keyImage = toHex(readBytes(32));
console.log(` amount: ${amount}, asset: "${assetType}", offsets(${numOffsets}): [${offsets.slice(0, 3).join(',')}${numOffsets > 3 ? ',...' : ''}], ki: ${keyImage.slice(0, 16)}...`);
}
}
// Outputs
const numOutputs = Number(readVarint());
console.log(`\nvout count: ${numOutputs}`);
for (let i = 0; i < numOutputs; i++) {
const amount = readVarint();
const tag = bytes[pos++];
console.log(` vout[${i}] amount: ${amount}, tag: 0x${tag.toString(16)}`);
if (tag === 0x03) { // txout_to_tagged_key
const key = toHex(readBytes(32));
const assetType = readString();
const unlockTime = readVarint();
const viewTag = bytes[pos++];
console.log(` key: ${key.slice(0,16)}..., asset: "${assetType}", unlock: ${unlockTime}, view_tag: 0x${viewTag.toString(16).padStart(2,'0')}`);
} else if (tag === 0x02) { // txout_to_key
const key = toHex(readBytes(32));
const assetType = readString();
const unlockTime = readVarint();
console.log(` key: ${key.slice(0,16)}..., asset: "${assetType}", unlock: ${unlockTime}`);
}
}
// Extra
const extraLen = Number(readVarint());
const extraBytes = readBytes(extraLen);
console.log(`\nextra: ${extraLen} bytes`);
// Salvium prefix fields
const txType = readVarint();
const txTypeNames = {0:'UNSET',1:'MINER',2:'PROTOCOL',3:'TRANSFER',4:'BURN',5:'CONVERT',6:'YIELD',7:'STAKE',8:'RETURN',9:'AUDIT'};
console.log(`\ntype: ${txType} (${txTypeNames[Number(txType)] || '?'})`);
if (txType !== 0n && txType !== 2n) {
const amountBurnt = readVarint();
console.log(`amount_burnt: ${amountBurnt}`);
if (txType !== 1n) {
// return address handling depends on version
if (txType === 3n && version >= 3n) {
// return_address_list
const listLen = Number(readVarint());
console.log(`return_address_list count: ${listLen}`);
for (let i = 0; i < listLen; i++) {
const addr = toHex(readBytes(32));
console.log(` [${i}]: ${addr.slice(0,16)}...`);
}
const maskLen = Number(readVarint());
const mask = readBytes(maskLen);
console.log(`return_address_change_mask: ${maskLen} bytes: ${toHex(mask)}`);
} else if (txType === 7n && version >= 4n) {
console.log('protocol_tx_data (STAKE+CARROT) — skipping');
} else {
const returnAddr = toHex(readBytes(32));
const returnPubkey = toHex(readBytes(32));
console.log(`return_address: ${returnAddr.slice(0,16)}...`);
console.log(`return_pubkey: ${returnPubkey.slice(0,16)}...`);
}
const srcAsset = readString();
const dstAsset = readString();
const slippage = readVarint();
console.log(`source_asset_type: "${srcAsset}"`);
console.log(`destination_asset_type: "${dstAsset}"`);
console.log(`amount_slippage_limit: ${slippage}`);
}
}
console.log(`\nPrefix parsed up to byte ${pos}/${bytes.length}`);
console.log(`\n=== RCT SIGNATURES ===`);
const rctType = bytes[pos++];
console.log(`rct_type: ${rctType}`);
if (rctType > 0) {
const fee = readVarint();
console.log(`fee: ${fee}`);
console.log(`\nRemaining bytes: ${bytes.length - pos}`);
}
// Write hex to file for reference
await Bun.write('/tmp/debug-tx.hex', hex);
console.log('\nTX hex written to /tmp/debug-tx.hex');
+43 -45
View File
@@ -1,47 +1,45 @@
#!/usr/bin/env bun
// Debug specific failing transaction
import { DaemonRPC } from '../src/rpc/daemon.js';
import { transfer } from '../src/wallet/transfer.js';
import { WalletSync } from '../src/wallet-sync.js';
import { createWallet } from '../src/wallet/wallet.js';
import { readFileSync, unlinkSync } from 'fs';
import { createDaemonRPC } from '../src/rpc/index.js';
import { parseTransaction } from '../src/transaction.js';
import { hexToBytes, bytesToHex } from '../src/address.js';
const daemon = createDaemonRPC({ url: 'http://seed01.salvium.io:19081', timeout: 30000 });
async function test() {
const txHash = 'a5e85d03e9200229a72fc3cfe94e5b139c8c2ad982dc3fd174898be63269e670';
console.log('Fetching tx:', txHash);
const resp = await daemon.getTransactions([txHash], { decode_as_json: true });
const txData = resp.result?.txs?.[0];
if (!txData) {
console.log('Transaction not found');
return;
}
console.log('TX size:', txData.as_hex.length / 2, 'bytes');
// Get daemon's JSON decode for comparison
const jsonTx = JSON.parse(txData.as_json);
console.log('\nDaemon JSON says:');
console.log(' RCT type:', jsonTx.rct_signatures?.type);
console.log(' Inputs:', jsonTx.vin?.length);
console.log(' Outputs:', jsonTx.vout?.length);
// Try to parse
console.log('\nParsing...');
try {
const tx = parseTransaction(hexToBytes(txData.as_hex));
console.log('Parsed successfully');
console.log(' RCT type:', tx.rct?.type);
console.log(' Has bulletproofPlus:', !!tx.rct?.bulletproofPlus);
console.log(' Has CLSAGs:', !!tx.rct?.CLSAGs);
console.log(' ecdhInfo count:', tx.rct?.ecdhInfo?.length);
console.log(' outPk count:', tx.rct?.outPk?.length);
} catch (e) {
console.log('Parse error:', e.message);
}
}
test().catch(console.error);
const daemon = new DaemonRPC('http://web.whiskymine.io:29081');
const walletAJson = JSON.parse(readFileSync('/home/mxhess/testnet-wallet/wallet.json', 'utf-8'));
const keysA = {
viewSecretKey: walletAJson.viewSecretKey,
spendSecretKey: walletAJson.spendSecretKey,
viewPublicKey: walletAJson.viewPublicKey,
spendPublicKey: walletAJson.spendPublicKey,
address: walletAJson.address
};
try { unlinkSync('/home/mxhess/testnet-wallet/wallet-sync-debug.json'); } catch {}
const sync = new WalletSync({ daemon, keys: keysA, network: 'testnet' });
const storage = await sync.sync({ savePath: '/home/mxhess/testnet-wallet/wallet-sync-debug.json' });
const info = await daemon.getInfo();
const height = info.result?.height;
console.log('Height:', height);
const allOutputs = await storage.getOutputs({ isSpent: false });
const spendable = allOutputs.filter(o => o.isSpendable(height));
console.log('Spendable:', spendable.length);
// Generate a random address
import { generateKeys } from '../src/crypto/index.js';
import { encodeAddress } from '../src/address.js';
const { TESTNET_CONFIG } from '../src/consensus.js';
const bKeys = generateKeys();
const toAddress = encodeAddress(TESTNET_CONFIG.ADDRESS_PREFIX, bKeys.viewPublicKey, bKeys.spendPublicKey);
const result = await transfer({
wallet: { keys: keysA, storage },
daemon,
destinations: [{ address: toAddress, amount: 100_0000_0000n }],
options: { priority: 'default', network: 'testnet', dryRun: true }
});
console.log('TX hex length:', result.serializedHex.length / 2, 'bytes');
const resp = await fetch('http://web.whiskymine.io:29081/sendrawtransaction', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tx_as_hex: result.serializedHex, do_not_relay: true })
});
const data = await resp.json();
console.log('Daemon check:', JSON.stringify(data, null, 2));
+83 -5
View File
@@ -14,22 +14,47 @@
* DRY_RUN=0 bun test/integration-transfer.test.js
*/
import { setCryptoBackend } from '../src/crypto/index.js';
import { DaemonRPC } from '../src/rpc/daemon.js';
import { Wallet } from '../src/wallet.js';
import { createWalletSync } from '../src/wallet-sync.js';
import { MemoryStorage } from '../src/wallet-store.js';
import { transfer, sweep, stake } from '../src/wallet/transfer.js';
import { existsSync } from 'node:fs';
// Initialize WASM backend for correct hash-to-point (matches C++ ge_fromfe_frombytes_vartime)
await setCryptoBackend('wasm');
const DAEMON_URL = process.env.DAEMON_URL || 'http://web.whiskymine.io:29081';
const WALLET_FILE = process.env.WALLET_FILE || `${process.env.HOME}/testnet-wallet/wallet.json`;
const NETWORK = process.env.NETWORK || 'testnet';
const DRY_RUN = process.env.DRY_RUN !== '0';
// Optional sync cache — set to a file path to persist wallet sync state between runs.
// Set SYNC_CACHE=0 to disable. Default: alongside wallet file.
const SYNC_CACHE = process.env.SYNC_CACHE === '0' ? null
: (process.env.SYNC_CACHE || WALLET_FILE.replace(/\.json$/, '-sync.json'));
let daemon;
async function syncWallet(label, keys) {
console.log(`Syncing ${label}...`);
async function syncWallet(label, keys, cacheFile = null) {
const storage = new MemoryStorage();
// Try loading cached sync state
let cachedHeight = 0;
if (cacheFile && existsSync(cacheFile)) {
try {
const cached = JSON.parse(await Bun.file(cacheFile).text());
storage.load(cached);
cachedHeight = await storage.getSyncHeight();
console.log(`Syncing ${label}... (resuming from block ${cachedHeight})`);
} catch (e) {
console.log(`Syncing ${label}... (cache unreadable, starting fresh)`);
}
} else {
console.log(`Syncing ${label}...`);
}
const sync = createWalletSync({
daemon,
keys,
@@ -39,6 +64,15 @@ async function syncWallet(label, keys) {
await sync.start();
// Save sync state for next run
if (cacheFile) {
await Bun.write(cacheFile, JSON.stringify(storage.dump()));
const newHeight = await storage.getSyncHeight();
if (newHeight > cachedHeight) {
console.log(` Synced ${newHeight - cachedHeight} new blocks (saved to ${cacheFile})`);
}
}
const infoResp = await daemon.getInfo();
const currentHeight = infoResp.result?.height || infoResp.data?.height || 0;
@@ -49,8 +83,17 @@ async function syncWallet(label, keys) {
let spendableBalance = 0n;
for (const o of spendable) spendableBalance += o.amount;
// Detect dominant asset type
const assetCounts = {};
for (const o of spendable) {
const a = o.assetType || 'SAL';
assetCounts[a] = (assetCounts[a] || 0) + 1;
}
const detectedAsset = Object.entries(assetCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'SAL';
console.log(` ${allOutputs.length} unspent (${spendable.length} spendable)`);
console.log(` Balance: ${Number(balance) / 1e8} SAL (${Number(spendableBalance) / 1e8} SAL spendable)`);
console.log(` Asset types: ${JSON.stringify(assetCounts)}`);
console.log(` Balance: ${Number(balance) / 1e8} ${detectedAsset} (${Number(spendableBalance) / 1e8} ${detectedAsset} spendable)`);
return { sync, storage, balance, spendableBalance, outputs: allOutputs, spendable };
}
@@ -65,7 +108,7 @@ async function testTransfer(keys, storage, toAddress, amount, label) {
wallet: { keys, storage },
daemon,
destinations: [{ address: toAddress, amount }],
options: { priority: 'default', dryRun: DRY_RUN }
options: { priority: 'default', network: NETWORK, dryRun: DRY_RUN }
});
console.log(` TX Hash: ${result.txHash}`);
@@ -73,6 +116,14 @@ async function testTransfer(keys, storage, toAddress, amount, label) {
console.log(` Inputs: ${result.inputCount}, Outputs: ${result.outputCount}`);
console.log(` Serialized: ${result.serializedHex.length / 2} bytes`);
console.log(` ${DRY_RUN ? '(dry run — not broadcast)' : 'BROADCAST OK'}`);
// Mark spent outputs so subsequent transfers don't re-use them
if (!DRY_RUN && result.spentKeyImages) {
for (const keyImage of result.spentKeyImages) {
await storage.markOutputSpent(keyImage);
}
}
return result;
} catch (e) {
console.error(` FAILED: ${e.message}`);
@@ -118,7 +169,7 @@ async function main() {
console.log(` Address: ${addressB}\n`);
// Sync wallet A
const syncA = await syncWallet('Wallet A', keysA);
const syncA = await syncWallet('Wallet A', keysA, SYNC_CACHE);
if (syncA.spendableBalance === 0n) {
console.log('\nWallet A has no spendable balance. Mine more blocks.');
@@ -163,6 +214,33 @@ async function main() {
console.log(` Inputs: ${stakeResult.inputCount}, Outputs: ${stakeResult.outputCount}`);
console.log(` Serialized: ${stakeResult.serializedHex.length / 2} bytes`);
console.log(` ${DRY_RUN ? '(dry run — not broadcast)' : 'BROADCAST OK'}`);
// Mark spent outputs so sweep doesn't re-use them
if (!DRY_RUN && stakeResult.spentKeyImages) {
for (const keyImage of stakeResult.spentKeyImages) {
await syncA.storage.markOutputSpent(keyImage);
}
}
} catch (e) {
console.error(` FAILED: ${e.message}`);
if (e.stack) console.error(e.stack.split('\n').slice(1, 4).join('\n'));
}
// Test 4: Sweep all back to self (wallet A)
console.log('\n=== Sweep Test (A → A) ===');
try {
const sweepResult = await sweep({
wallet: { keys: keysA, storage: syncA.storage },
daemon,
address: walletAJson.address,
options: { priority: 'default', network: NETWORK, dryRun: DRY_RUN }
});
console.log(` TX Hash: ${sweepResult.txHash}`);
console.log(` Fee: ${Number(sweepResult.fee) / 1e8} SAL`);
console.log(` Amount: ${Number(sweepResult.amount) / 1e8} SAL`);
console.log(` Inputs: ${sweepResult.inputCount}`);
console.log(` Serialized: ${sweepResult.serializedHex.length / 2} bytes`);
console.log(` ${DRY_RUN ? '(dry run — not broadcast)' : 'BROADCAST OK'}`);
} catch (e) {
console.error(` FAILED: ${e.message}`);
if (e.stack) console.error(e.stack.split('\n').slice(1, 4).join('\n'));
+1 -1
View File
@@ -301,7 +301,7 @@ test('buildTransaction creates valid transaction structure', () => {
assert(tx._meta, 'Should have metadata');
// Check prefix
assertEqual(tx.prefix.version, TX_VERSION.RCT_2);
assertEqual(tx.prefix.version, TX_VERSION.V2);
assertEqual(tx.prefix.vin.length, 1, 'Should have 1 input');
assertEqual(tx.prefix.vout.length, 2, 'Should have 2 outputs (dest + change)');