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:
+90
-48
@@ -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());
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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)');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user