Include staked balance in wallet total, add stake storage FFI + diagnostics

- Fix balance computation: staked amounts now included in total balance
     to match C++ wallet behavior (balance = unspent + staked, locked =
     immature + staked). Affects getBalance, getStorageBalance, and
     integration sync test reporting.
   - Add stake lifecycle methods to Rust storage (putStake, getStake,
     getStakes, getStakeByOutputKey, markStakeReturned, deleteStakesAbove)
     with FFI bindings and JS FfiStorage wrappers.
   - Fix BigInt serialization in FfiStorage.putStake (JSON.stringify crash).
   - Add balance diagnostic script (test/diagnose-balance.js) for verifying
     key image spent status against the blockchain.
   - Integration sync test: clear stale DB on fresh sync, add per-txType
     and per-format unspent breakdowns, show staked balance in report.
This commit is contained in:
Matt Hess
2026-02-15 22:26:24 +00:00
parent 2db263922b
commit ff15a54c30
40 changed files with 3322 additions and 2205 deletions
+3
View File
@@ -40,6 +40,9 @@ serde = { version = "1", features = ["derive"] }
default = []
ffi = []
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
[profile.release]
opt-level = 3
lto = true
+265
View File
@@ -571,6 +571,23 @@ pub unsafe extern "C" fn salvium_x25519_scalar_mult(
0
}
// ─── Edwards to Montgomery Conversion ────────────────────────────────────────
/// Convert Ed25519 compressed point to X25519 u-coordinate.
/// point: 32-byte compressed Ed25519 point.
/// out: 32-byte result u-coordinate.
/// Returns 0 on success.
#[no_mangle]
pub unsafe extern "C" fn salvium_edwards_to_montgomery_u(
point: *const u8,
out: *mut u8,
) -> i32 {
let point = slice::from_raw_parts(point, 32);
let result = crate::edwards_to_montgomery_u(point);
ptr::copy_nonoverlapping(result.as_ptr(), out, 32);
0
}
// ─── CLSAG Ring Signatures ──────────────────────────────────────────────────
/// CLSAG sign. Output buffer must be ring_count*32 + 96 bytes.
@@ -1453,6 +1470,89 @@ pub unsafe extern "C" fn salvium_compute_tx_prefix_hash(
})
}
// ─── Transaction Parsing & Serialization (full TX) ──────────────────────────
/// Parse a complete transaction from raw bytes to JSON string.
/// Rust allocates result buffer; caller frees with salvium_storage_free_buf.
/// Returns 0 on success, -1 on error.
#[no_mangle]
pub unsafe extern "C" fn salvium_parse_transaction(
data: *const u8,
data_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
catch_ffi(|| {
let input = slice::from_raw_parts(data, data_len);
match crate::tx_parse::parse_transaction(input) {
Ok(json) => {
let bytes = json.into_bytes().into_boxed_slice();
let len = bytes.len();
let raw = Box::into_raw(bytes);
*out_ptr = (*raw).as_mut_ptr();
*out_len = len;
0
}
Err(_) => -1,
}
})
}
/// Serialize a transaction from JSON string to raw bytes.
/// Rust allocates result buffer; caller frees with salvium_storage_free_buf.
/// Returns 0 on success, -1 on error.
#[no_mangle]
pub unsafe extern "C" fn salvium_serialize_transaction(
json: *const u8,
json_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
catch_ffi(|| {
let json_str = match std::str::from_utf8(slice::from_raw_parts(json, json_len)) {
Ok(s) => s,
Err(_) => return -1,
};
match crate::tx_serialize::serialize_transaction(json_str) {
Ok(result) => {
let len = result.len();
let buf = result.into_boxed_slice();
let raw = Box::into_raw(buf);
*out_ptr = (*raw).as_mut_ptr();
*out_len = len;
0
}
Err(_) => -1,
}
})
}
/// Parse a complete block from raw bytes to JSON string.
/// Rust allocates result buffer; caller frees with salvium_storage_free_buf.
/// Returns 0 on success, -1 on error.
#[no_mangle]
pub unsafe extern "C" fn salvium_parse_block(
data: *const u8,
data_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
catch_ffi(|| {
let input = slice::from_raw_parts(data, data_len);
match crate::tx_parse::parse_block(input) {
Ok(json) => {
let bytes = json.into_bytes().into_boxed_slice();
let len = bytes.len();
let raw = Box::into_raw(bytes);
*out_ptr = (*raw).as_mut_ptr();
*out_len = len;
0
}
Err(_) => -1,
}
})
}
// ─── Storage (SQLCipher) ────────────────────────────────────────────────────
/// Open/create an encrypted SQLite database.
@@ -1867,6 +1967,171 @@ pub unsafe extern "C" fn salvium_storage_get_all_balances(
})
}
// ─── Stake Storage FFI ──────────────────────────────────────────────────────
#[no_mangle]
pub unsafe extern "C" fn salvium_storage_put_stake(
handle: u32,
json_buf: *const u8,
json_len: usize,
) -> i32 {
catch_ffi(|| {
let json_str = match std::str::from_utf8(slice::from_raw_parts(json_buf, json_len)) {
Ok(s) => s,
Err(e) => { eprintln!("salvium_storage_put_stake: invalid UTF-8: {e}"); return -1; }
};
let stake: crate::storage::StakeRow = match serde_json::from_str(json_str) {
Ok(o) => o,
Err(e) => { eprintln!("salvium_storage_put_stake: JSON parse error: {e}"); return -1; }
};
match crate::storage::with_db(handle, |db| db.put_stake(&stake)) {
Ok(()) => 0,
Err(e) => { eprintln!("salvium_storage_put_stake: DB error: {e}"); -1 }
}
})
}
#[no_mangle]
pub unsafe extern "C" fn salvium_storage_get_stake(
handle: u32,
hash_buf: *const u8,
hash_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
catch_ffi(|| {
let hash_str = match std::str::from_utf8(slice::from_raw_parts(hash_buf, hash_len)) {
Ok(s) => s,
Err(_) => return -1,
};
match crate::storage::with_db(handle, |db| db.get_stake(hash_str)) {
Ok(Some(stake)) => {
let json = match serde_json::to_vec(&stake) {
Ok(j) => j,
Err(_) => return -1,
};
let len = json.len();
let buf = json.into_boxed_slice();
let raw = Box::into_raw(buf);
*out_ptr = (*raw).as_mut_ptr();
*out_len = len;
0
}
Ok(None) => -1,
Err(e) => { eprintln!("salvium_storage_get_stake: {e}"); -1 }
}
})
}
#[no_mangle]
pub unsafe extern "C" fn salvium_storage_get_stakes(
handle: u32,
query_buf: *const u8,
query_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
catch_ffi(|| {
let query_str = match std::str::from_utf8(slice::from_raw_parts(query_buf, query_len)) {
Ok(s) => s,
Err(_) => return -1,
};
let query: serde_json::Value = serde_json::from_str(query_str)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
let status = query.get("status").and_then(|v| v.as_str());
let asset_type = query.get("assetType").and_then(|v| v.as_str());
match crate::storage::with_db(handle, |db| db.get_stakes(status, asset_type)) {
Ok(stakes) => {
let json = match serde_json::to_vec(&stakes) {
Ok(j) => j,
Err(_) => return -1,
};
let len = json.len();
let buf = json.into_boxed_slice();
let raw = Box::into_raw(buf);
*out_ptr = (*raw).as_mut_ptr();
*out_len = len;
0
}
Err(e) => { eprintln!("salvium_storage_get_stakes: {e}"); -1 }
}
})
}
#[no_mangle]
pub unsafe extern "C" fn salvium_storage_get_stake_by_output_key(
handle: u32,
key_buf: *const u8,
key_len: usize,
out_ptr: *mut *mut u8,
out_len: *mut usize,
) -> i32 {
catch_ffi(|| {
let key_str = match std::str::from_utf8(slice::from_raw_parts(key_buf, key_len)) {
Ok(s) => s,
Err(_) => return -1,
};
match crate::storage::with_db(handle, |db| db.get_stake_by_output_key(key_str)) {
Ok(Some(stake)) => {
let json = match serde_json::to_vec(&stake) {
Ok(j) => j,
Err(_) => return -1,
};
let len = json.len();
let buf = json.into_boxed_slice();
let raw = Box::into_raw(buf);
*out_ptr = (*raw).as_mut_ptr();
*out_len = len;
0
}
Ok(None) => -1,
Err(e) => { eprintln!("salvium_storage_get_stake_by_output_key: {e}"); -1 }
}
})
}
#[no_mangle]
pub unsafe extern "C" fn salvium_storage_mark_stake_returned(
handle: u32,
json_buf: *const u8,
json_len: usize,
) -> i32 {
catch_ffi(|| {
let json_str = match std::str::from_utf8(slice::from_raw_parts(json_buf, json_len)) {
Ok(s) => s,
Err(e) => { eprintln!("salvium_storage_mark_stake_returned: invalid UTF-8: {e}"); return -1; }
};
let data: serde_json::Value = match serde_json::from_str(json_str) {
Ok(v) => v,
Err(e) => { eprintln!("salvium_storage_mark_stake_returned: JSON parse error: {e}"); return -1; }
};
let stake_tx_hash = data["stakeTxHash"].as_str().unwrap_or("");
let return_tx_hash = data["returnTxHash"].as_str().unwrap_or("");
let return_height = data["returnHeight"].as_i64().unwrap_or(0);
let return_timestamp = data["returnTimestamp"].as_i64().unwrap_or(0);
let return_amount = data["returnAmount"].as_str().unwrap_or("0");
match crate::storage::with_db(handle, |db| {
db.mark_stake_returned(stake_tx_hash, return_tx_hash, return_height, return_timestamp, return_amount)
}) {
Ok(()) => 0,
Err(e) => { eprintln!("salvium_storage_mark_stake_returned: DB error: {e}"); -1 }
}
})
}
#[no_mangle]
pub unsafe extern "C" fn salvium_storage_delete_stakes_above(
handle: u32,
height: i64,
) -> i32 {
catch_ffi(|| {
match crate::storage::with_db(handle, |db| db.delete_stakes_above(height)) {
Ok(()) => 0,
Err(e) => { eprintln!("salvium_storage_delete_stakes_above: DB error: {e}"); -1 }
}
})
}
/// Free Rust-allocated result buffer.
#[no_mangle]
pub unsafe extern "C" fn salvium_storage_free_buf(buf_ptr: *mut u8, len: usize) {
+38
View File
@@ -12,6 +12,9 @@ pub mod cn_scan;
pub mod subaddress;
pub mod carrot_keys;
pub mod tx_format;
pub mod tx_constants;
pub mod tx_parse;
pub mod tx_serialize;
pub(crate) mod elligator2;
pub mod clsag;
@@ -512,6 +515,13 @@ pub fn x25519_scalar_mult(scalar: &[u8], u_coord: &[u8]) -> Vec<u8> {
x25519::montgomery_ladder(&s, &to32(u_coord)).to_vec()
}
/// Convert Ed25519 compressed point to X25519 u-coordinate.
/// u = (1 + y) / (1 - y) mod p
#[wasm_bindgen]
pub fn edwards_to_montgomery_u(point: &[u8]) -> Vec<u8> {
x25519::edwards_to_montgomery_u(&to32(point)).to_vec()
}
// ─── Batch Subaddress Map Generation ────────────────────────────────────────
/// Generate CryptoNote subaddress map in a single call.
@@ -653,3 +663,31 @@ pub fn serialize_tx_extra(json_str: &str) -> Vec<u8> {
pub fn compute_tx_prefix_hash(data: &[u8]) -> Vec<u8> {
tx_format::compute_tx_prefix_hash(data).to_vec()
}
// ─── Transaction Parsing & Serialization ─────────────────────────────────────
/// Parse a complete transaction from raw bytes to JSON string.
/// Returns JSON with hex-encoded binary fields and decimal string amounts.
#[wasm_bindgen]
pub fn parse_transaction_bytes(data: &[u8]) -> String {
match tx_parse::parse_transaction(data) {
Ok(json) => json,
Err(e) => format!(r#"{{"error":"{}"}}"#, e.replace('"', "\\\"")),
}
}
/// Serialize a transaction from JSON string to raw bytes.
/// Returns empty Vec on error.
#[wasm_bindgen]
pub fn serialize_transaction_json(json: &str) -> Vec<u8> {
tx_serialize::serialize_transaction(json).unwrap_or_default()
}
/// Parse a complete block from raw bytes to JSON string.
#[wasm_bindgen]
pub fn parse_block_bytes(data: &[u8]) -> String {
match tx_parse::parse_block(data) {
Ok(json) => json,
Err(e) => format!(r#"{{"error":"{}"}}"#, e.replace('"', "\\\"")),
}
}
+205
View File
@@ -97,6 +97,25 @@ CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS stakes (
stake_tx_hash TEXT PRIMARY KEY,
stake_height INTEGER,
stake_timestamp INTEGER,
amount_staked TEXT NOT NULL DEFAULT '0',
fee TEXT NOT NULL DEFAULT '0',
asset_type TEXT NOT NULL DEFAULT 'SAL',
change_output_key TEXT,
status TEXT NOT NULL DEFAULT 'locked',
return_tx_hash TEXT,
return_height INTEGER,
return_timestamp INTEGER,
return_amount TEXT NOT NULL DEFAULT '0',
created_at INTEGER,
updated_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_stakes_status ON stakes(status);
CREATE UNIQUE INDEX IF NOT EXISTS idx_stakes_output_key ON stakes(change_output_key) WHERE change_output_key IS NOT NULL;
";
// ─── Data Models ────────────────────────────────────────────────────────────
@@ -228,9 +247,34 @@ pub struct BalanceResult {
pub locked_balance: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StakeRow {
pub stake_tx_hash: String,
pub stake_height: Option<i64>,
pub stake_timestamp: Option<i64>,
#[serde(default = "default_zero_str")]
pub amount_staked: String,
#[serde(default = "default_zero_str")]
pub fee: String,
#[serde(default = "default_sal")]
pub asset_type: String,
pub change_output_key: Option<String>,
#[serde(default = "default_locked")]
pub status: String,
pub return_tx_hash: Option<String>,
pub return_height: Option<i64>,
pub return_timestamp: Option<i64>,
#[serde(default = "default_zero_str")]
pub return_amount: String,
pub created_at: Option<i64>,
pub updated_at: Option<i64>,
}
fn default_zero_str() -> String { "0".to_string() }
fn default_sal() -> String { "SAL".to_string() }
fn default_tx_type() -> i64 { 3 }
fn default_locked() -> String { "locked".to_string() }
/// Deserialize tx_type from either an integer or a string name.
/// Accepts: 0-8 (integers), "miner", "protocol", "transfer", "convert", "burn", "stake", "return", "audit"
@@ -736,6 +780,167 @@ impl WalletDb {
}
Ok(result)
}
// ── Stake Operations ────────────────────────────────────────────────
pub fn put_stake(&self, row: &StakeRow) -> Result<(), rusqlite::Error> {
let now = now_millis();
self.conn.execute(
"INSERT OR REPLACE INTO stakes (
stake_tx_hash, stake_height, stake_timestamp,
amount_staked, fee, asset_type, change_output_key,
status, return_tx_hash, return_height, return_timestamp,
return_amount, created_at, updated_at
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14
)",
params![
row.stake_tx_hash, row.stake_height, row.stake_timestamp,
row.amount_staked, row.fee, row.asset_type, row.change_output_key,
row.status, row.return_tx_hash, row.return_height, row.return_timestamp,
row.return_amount, row.created_at.unwrap_or(now), now
],
)?;
Ok(())
}
pub fn get_stake(&self, stake_tx_hash: &str) -> Result<Option<StakeRow>, rusqlite::Error> {
self.conn.query_row(
"SELECT stake_tx_hash, stake_height, stake_timestamp,
amount_staked, fee, asset_type, change_output_key,
status, return_tx_hash, return_height, return_timestamp,
return_amount, created_at, updated_at
FROM stakes WHERE stake_tx_hash = ?1",
params![stake_tx_hash],
|r| Ok(StakeRow {
stake_tx_hash: r.get(0)?,
stake_height: r.get(1)?,
stake_timestamp: r.get(2)?,
amount_staked: r.get(3)?,
fee: r.get(4)?,
asset_type: r.get(5)?,
change_output_key: r.get(6)?,
status: r.get(7)?,
return_tx_hash: r.get(8)?,
return_height: r.get(9)?,
return_timestamp: r.get(10)?,
return_amount: r.get(11)?,
created_at: r.get(12)?,
updated_at: r.get(13)?,
}),
).optional()
}
pub fn get_stakes(&self, status: Option<&str>, asset_type: Option<&str>) -> Result<Vec<StakeRow>, rusqlite::Error> {
let mut sql = String::from(
"SELECT stake_tx_hash, stake_height, stake_timestamp,
amount_staked, fee, asset_type, change_output_key,
status, return_tx_hash, return_height, return_timestamp,
return_amount, created_at, updated_at
FROM stakes WHERE 1=1"
);
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(s) = status {
sql.push_str(&format!(" AND status = ?{}", param_values.len() + 1));
param_values.push(Box::new(s.to_string()));
}
if let Some(at) = asset_type {
sql.push_str(&format!(" AND asset_type = ?{}", param_values.len() + 1));
param_values.push(Box::new(at.to_string()));
}
sql.push_str(" ORDER BY stake_height ASC");
let params_ref: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|b| b.as_ref()).collect();
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map(params_ref.as_slice(), |r| {
Ok(StakeRow {
stake_tx_hash: r.get(0)?,
stake_height: r.get(1)?,
stake_timestamp: r.get(2)?,
amount_staked: r.get(3)?,
fee: r.get(4)?,
asset_type: r.get(5)?,
change_output_key: r.get(6)?,
status: r.get(7)?,
return_tx_hash: r.get(8)?,
return_height: r.get(9)?,
return_timestamp: r.get(10)?,
return_amount: r.get(11)?,
created_at: r.get(12)?,
updated_at: r.get(13)?,
})
})?;
let mut results = Vec::new();
for row in rows {
results.push(row?);
}
Ok(results)
}
pub fn get_stake_by_output_key(&self, change_output_key: &str) -> Result<Option<StakeRow>, rusqlite::Error> {
self.conn.query_row(
"SELECT stake_tx_hash, stake_height, stake_timestamp,
amount_staked, fee, asset_type, change_output_key,
status, return_tx_hash, return_height, return_timestamp,
return_amount, created_at, updated_at
FROM stakes WHERE change_output_key = ?1",
params![change_output_key],
|r| Ok(StakeRow {
stake_tx_hash: r.get(0)?,
stake_height: r.get(1)?,
stake_timestamp: r.get(2)?,
amount_staked: r.get(3)?,
fee: r.get(4)?,
asset_type: r.get(5)?,
change_output_key: r.get(6)?,
status: r.get(7)?,
return_tx_hash: r.get(8)?,
return_height: r.get(9)?,
return_timestamp: r.get(10)?,
return_amount: r.get(11)?,
created_at: r.get(12)?,
updated_at: r.get(13)?,
}),
).optional()
}
pub fn mark_stake_returned(
&self,
stake_tx_hash: &str,
return_tx_hash: &str,
return_height: i64,
return_timestamp: i64,
return_amount: &str,
) -> Result<(), rusqlite::Error> {
let now = now_millis();
self.conn.execute(
"UPDATE stakes SET status = 'returned', return_tx_hash = ?1, return_height = ?2,
return_timestamp = ?3, return_amount = ?4, updated_at = ?5
WHERE stake_tx_hash = ?6",
params![return_tx_hash, return_height, return_timestamp, return_amount, now, stake_tx_hash],
)?;
Ok(())
}
pub fn delete_stakes_above(&self, height: i64) -> Result<(), rusqlite::Error> {
let now = now_millis();
// Delete stakes created above the height
self.conn.execute(
"DELETE FROM stakes WHERE stake_height > ?1",
params![height],
)?;
// Undo returns above the height (revert to 'locked')
self.conn.execute(
"UPDATE stakes SET status = 'locked', return_tx_hash = NULL, return_height = NULL,
return_timestamp = NULL, return_amount = '0', updated_at = ?1
WHERE return_height > ?2",
params![now, height],
)?;
Ok(())
}
}
// ─── Unlock Logic ───────────────────────────────────────────────────────────
+43
View File
@@ -0,0 +1,43 @@
//! Transaction type, RCT type, and I/O type constants.
//!
//! Port of `src/transaction/constants.js` — shared between tx_parse and tx_serialize.
// ─── Transaction Types (cryptonote_protocol/enums.h) ─────────────────────────
pub const TX_TYPE_UNSET: u8 = 0;
pub const TX_TYPE_MINER: u8 = 1;
pub const TX_TYPE_PROTOCOL: u8 = 2;
pub const TX_TYPE_TRANSFER: u8 = 3;
pub const TX_TYPE_CONVERT: u8 = 4;
pub const TX_TYPE_BURN: u8 = 5;
pub const TX_TYPE_STAKE: u8 = 6;
pub const TX_TYPE_RETURN: u8 = 7;
pub const TX_TYPE_AUDIT: u8 = 8;
// ─── RingCT Types ────────────────────────────────────────────────────────────
pub const RCT_TYPE_NULL: u8 = 0;
pub const RCT_TYPE_FULL: u8 = 1;
pub const RCT_TYPE_SIMPLE: u8 = 2;
pub const RCT_TYPE_BULLETPROOF: u8 = 3;
pub const RCT_TYPE_BULLETPROOF2: u8 = 4;
pub const RCT_TYPE_CLSAG: u8 = 5;
pub const RCT_TYPE_BULLETPROOF_PLUS: u8 = 6;
pub const RCT_TYPE_FULL_PROOFS: u8 = 7;
pub const RCT_TYPE_SALVIUM_ZERO: u8 = 8;
pub const RCT_TYPE_SALVIUM_ONE: u8 = 9;
// ─── Transaction Input Types ─────────────────────────────────────────────────
pub const TXIN_GEN: u8 = 0xff;
pub const TXIN_KEY: u8 = 0x02;
// ─── Transaction Output Types ────────────────────────────────────────────────
pub const TXOUT_KEY: u8 = 0x02;
pub const TXOUT_TAGGED_KEY: u8 = 0x03;
pub const TXOUT_CARROT_V1: u8 = 0x04;
// ─── Network Parameters ──────────────────────────────────────────────────────
pub const HF_VERSION_ENABLE_ORACLE: u64 = 255;
+2 -2
View File
@@ -14,7 +14,7 @@
/// Decode a varint (LEB128) from bytes at offset.
/// Returns (value, bytes_read). Max 10 bytes / 70 bits.
fn decode_varint(data: &[u8], offset: usize) -> Option<(u64, usize)> {
pub(crate) fn decode_varint(data: &[u8], offset: usize) -> Option<(u64, usize)> {
let mut value: u64 = 0;
let mut shift: u32 = 0;
let mut i = 0;
@@ -37,7 +37,7 @@ fn decode_varint(data: &[u8], offset: usize) -> Option<(u64, usize)> {
}
/// Encode a u64 value as varint (LEB128).
fn encode_varint(mut value: u64) -> Vec<u8> {
pub(crate) fn encode_varint(mut value: u64) -> Vec<u8> {
let mut buf = Vec::with_capacity(10);
loop {
let byte = (value & 0x7F) as u8;
+994
View File
@@ -0,0 +1,994 @@
//! Full transaction binary parser.
//!
//! Parses raw Salvium transaction bytes into a JSON string, matching the
//! structure produced by `src/transaction/parsing.js`.
//!
//! Binary fields are hex-encoded strings, amounts are decimal strings,
//! small integers are numbers.
use crate::tx_constants::*;
use crate::tx_format::decode_varint;
use serde_json::{json, Value};
// ─── Cursor ──────────────────────────────────────────────────────────────────
struct Cursor<'a> {
data: &'a [u8],
offset: usize,
}
impl<'a> Cursor<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, offset: 0 }
}
fn remaining(&self) -> usize {
self.data.len().saturating_sub(self.offset)
}
fn read_bytes(&mut self, count: usize) -> Result<&'a [u8], String> {
if self.offset + count > self.data.len() {
return Err(format!(
"Unexpected end of data at offset {} (need {} bytes, have {})",
self.offset,
count,
self.remaining()
));
}
let slice = &self.data[self.offset..self.offset + count];
self.offset += count;
Ok(slice)
}
fn read_byte(&mut self) -> Result<u8, String> {
Ok(self.read_bytes(1)?[0])
}
fn read_varint(&mut self) -> Result<u64, String> {
match decode_varint(self.data, self.offset) {
Some((value, bytes_read)) => {
self.offset += bytes_read;
Ok(value)
}
None => Err(format!(
"Varint decode failed at offset {}",
self.offset
)),
}
}
fn read_u32_le(&mut self) -> Result<u32, String> {
let bytes = self.read_bytes(4)?;
Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
fn read_u64_le(&mut self) -> Result<u64, String> {
let bytes = self.read_bytes(8)?;
Ok(u64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5], bytes[6], bytes[7],
]))
}
fn read_string(&mut self) -> Result<String, String> {
let len = self.read_varint()? as usize;
if len == 0 {
return Ok(String::new());
}
let bytes = self.read_bytes(len)?;
String::from_utf8(bytes.to_vec())
.map_err(|e| format!("Invalid UTF-8 string at offset {}: {}", self.offset - len, e))
}
}
// ─── Hex helpers ─────────────────────────────────────────────────────────────
fn to_hex(bytes: &[u8]) -> String {
hex::encode(bytes)
}
// ─── Transaction Parsing ─────────────────────────────────────────────────────
/// Parse a complete transaction from raw bytes to JSON string.
///
/// Output JSON has the same structure as the JS `parseTransaction()`:
/// ```json
/// {
/// "prefix": { "version": ..., "unlockTime": ..., "vin": [...], "vout": [...], ... },
/// "rct": { "type": ..., "txnFee": "...", "ecdhInfo": [...], ... },
/// "_bytesRead": ...,
/// "_prefixEndOffset": ...
/// }
/// ```
pub fn parse_transaction(data: &[u8]) -> Result<String, String> {
let mut c = Cursor::new(data);
let tx = parse_transaction_inner(&mut c)?;
serde_json::to_string(&tx).map_err(|e| format!("JSON serialization error: {e}"))
}
fn parse_transaction_inner(c: &mut Cursor) -> Result<Value, String> {
// ─── TX Prefix ───────────────────────────────────────────────────────
let version = c.read_varint()?;
let unlock_time = c.read_varint()?;
// Inputs
let vin_count = c.read_varint()? as usize;
let mut vin = Vec::with_capacity(vin_count);
for _ in 0..vin_count {
let input_type = c.read_byte()?;
match input_type {
TXIN_GEN => {
let height = c.read_varint()?;
vin.push(json!({
"type": TXIN_GEN,
"height": height
}));
}
TXIN_KEY => {
let amount = c.read_varint()?;
let asset_type = c.read_string()?;
let key_offset_count = c.read_varint()? as usize;
let mut key_offsets = Vec::with_capacity(key_offset_count);
for _ in 0..key_offset_count {
key_offsets.push(c.read_varint()?);
}
let key_image = to_hex(c.read_bytes(32)?);
vin.push(json!({
"type": TXIN_KEY,
"amount": amount.to_string(),
"assetType": asset_type,
"keyOffsets": key_offsets,
"keyImage": key_image
}));
}
_ => return Err(format!("Unknown input type: {input_type}")),
}
}
// Outputs
let vout_count = c.read_varint()? as usize;
let mut vout = Vec::with_capacity(vout_count);
for _ in 0..vout_count {
let amount = c.read_varint()?;
let output_type = c.read_byte()?;
match output_type {
TXOUT_KEY => {
let key = to_hex(c.read_bytes(32)?);
let asset_type = c.read_string()?;
let output_unlock_time = c.read_varint()?;
vout.push(json!({
"type": TXOUT_KEY,
"amount": amount.to_string(),
"key": key,
"assetType": asset_type,
"unlockTime": output_unlock_time
}));
}
TXOUT_TAGGED_KEY => {
let key = to_hex(c.read_bytes(32)?);
let asset_type = c.read_string()?;
let output_unlock_time = c.read_varint()?;
let view_tag = c.read_byte()?;
vout.push(json!({
"type": TXOUT_TAGGED_KEY,
"amount": amount.to_string(),
"key": key,
"assetType": asset_type,
"unlockTime": output_unlock_time,
"viewTag": view_tag
}));
}
TXOUT_CARROT_V1 => {
let key = to_hex(c.read_bytes(32)?);
let asset_type = c.read_string()?;
let view_tag = to_hex(c.read_bytes(3)?);
let encrypted_janus_anchor = to_hex(c.read_bytes(16)?);
vout.push(json!({
"type": TXOUT_CARROT_V1,
"amount": amount.to_string(),
"key": key,
"assetType": asset_type,
"viewTag": view_tag,
"encryptedJanusAnchor": encrypted_janus_anchor
}));
}
_ => return Err(format!("Unknown output type: {output_type}")),
}
}
// Extra
let extra_size = c.read_varint()? as usize;
let extra_bytes = c.read_bytes(extra_size)?;
let extra_json_str = crate::tx_format::parse_extra(extra_bytes);
let extra: Value = serde_json::from_str(&extra_json_str)
.map_err(|e| format!("Extra parse error: {e}"))?;
// Salvium-specific prefix fields
let tx_type = c.read_varint()? as u8;
let mut amount_burnt = json!("0");
let mut return_address = Value::Null;
let mut return_address_list = Value::Null;
let mut return_address_change_mask = Value::Null;
let mut return_pubkey = Value::Null;
let mut source_asset_type = json!("");
let mut destination_asset_type = json!("");
let mut amount_slippage_limit = json!("0");
let mut protocol_tx_data = Value::Null;
if tx_type != TX_TYPE_UNSET && tx_type != TX_TYPE_PROTOCOL {
amount_burnt = json!(c.read_varint()?.to_string());
if tx_type != TX_TYPE_MINER {
if tx_type == TX_TYPE_TRANSFER && version >= 3 {
// TRANSFER v3+: return_address_list + change_mask
let list_count = c.read_varint()? as usize;
let mut list = Vec::with_capacity(list_count);
for _ in 0..list_count {
list.push(json!(to_hex(c.read_bytes(32)?)));
}
return_address_list = json!(list);
let mask_count = c.read_varint()? as usize;
return_address_change_mask = json!(to_hex(c.read_bytes(mask_count)?));
} else if tx_type == TX_TYPE_STAKE && version >= 4 {
// STAKE v4+: protocol_tx_data
let ptx_version = c.read_varint()?;
let ptx_return_addr = to_hex(c.read_bytes(32)?);
let ptx_return_pubkey = to_hex(c.read_bytes(32)?);
let ptx_view_tag = to_hex(c.read_bytes(3)?);
let ptx_anchor_enc = to_hex(c.read_bytes(16)?);
protocol_tx_data = json!({
"version": ptx_version,
"return_address": ptx_return_addr,
"return_pubkey": ptx_return_pubkey,
"return_view_tag": ptx_view_tag,
"return_anchor_enc": ptx_anchor_enc
});
} else {
return_address = json!(to_hex(c.read_bytes(32)?));
return_pubkey = json!(to_hex(c.read_bytes(32)?));
}
source_asset_type = json!(c.read_string()?);
destination_asset_type = json!(c.read_string()?);
amount_slippage_limit = json!(c.read_varint()?.to_string());
}
}
let prefix = json!({
"version": version,
"unlockTime": unlock_time,
"vin": vin,
"vout": vout,
"extra": extra,
"txType": tx_type,
"amount_burnt": amount_burnt,
"return_address": return_address,
"return_address_list": return_address_list,
"return_address_change_mask": return_address_change_mask,
"return_pubkey": return_pubkey,
"source_asset_type": source_asset_type,
"destination_asset_type": destination_asset_type,
"amount_slippage_limit": amount_slippage_limit,
"protocol_tx_data": protocol_tx_data
});
// V1 transactions: no RCT section
if version == 1 {
return Ok(json!({
"prefix": prefix,
"_bytesRead": c.offset,
"_prefixEndOffset": c.offset
}));
}
let prefix_end_offset = c.offset;
// Mixin from first input
let mixin = if let Some(first) = vin.first() {
first.get("keyOffsets")
.and_then(|v| v.as_array())
.map(|a| a.len().saturating_sub(1))
.unwrap_or(15)
} else {
15
};
// ─── RCT Signature ───────────────────────────────────────────────────
let rct = parse_ringct_signature(c, vin_count, vout_count, mixin)?;
let bytes_read = c.offset;
Ok(json!({
"prefix": prefix,
"rct": rct,
"_bytesRead": bytes_read,
"_prefixEndOffset": prefix_end_offset
}))
}
// ─── RingCT Parsing ──────────────────────────────────────────────────────────
fn parse_ringct_signature(
c: &mut Cursor,
input_count: usize,
output_count: usize,
mixin: usize,
) -> Result<Value, String> {
let rct_type = c.read_byte()?;
if rct_type == RCT_TYPE_NULL {
return Ok(json!({ "type": RCT_TYPE_NULL }));
}
// Validate type
if rct_type < RCT_TYPE_BULLETPROOF_PLUS || rct_type > RCT_TYPE_SALVIUM_ONE {
return Err(format!(
"Invalid RCT type: {} at offset {}",
rct_type,
c.offset - 1
));
}
// Fee
let fee = c.read_varint()?;
// ecdhInfo: 8 bytes per output (compact format for BP+ types)
let mut ecdh_info = Vec::with_capacity(output_count);
for _ in 0..output_count {
let amount = to_hex(c.read_bytes(8)?);
ecdh_info.push(json!({ "amount": amount }));
}
// outPk: 32 bytes per output
let mut out_pk = Vec::with_capacity(output_count);
for _ in 0..output_count {
out_pk.push(json!(to_hex(c.read_bytes(32)?)));
}
// p_r: Salvium-specific 32 bytes
let p_r = to_hex(c.read_bytes(32)?);
let mut rct = json!({
"type": rct_type,
"txnFee": fee.to_string(),
"ecdhInfo": ecdh_info,
"outPk": out_pk,
"p_r": p_r
});
// salvium_data based on type
match rct_type {
RCT_TYPE_SALVIUM_ZERO | RCT_TYPE_SALVIUM_ONE => {
match parse_salvium_data(c) {
Ok(sd) => {
rct["salvium_data"] = sd;
}
Err(e) => {
rct["salvium_data_parse_error"] = json!(e);
return Ok(rct);
}
}
}
RCT_TYPE_FULL_PROOFS => {
// Only pr_proof and sa_proof (2 x 96 bytes)
let pr_proof = parse_zk_proof(c)?;
let sa_proof = parse_zk_proof(c)?;
rct["salvium_data"] = json!({
"pr_proof": pr_proof,
"sa_proof": sa_proof
});
}
_ => {}
}
// ─── Prunable section ────────────────────────────────────────────────
if c.remaining() > 0 && rct_type != RCT_TYPE_NULL {
match parse_rct_sig_prunable(c, rct_type, input_count, output_count, mixin) {
Ok(prunable) => {
if let Some(bp) = prunable.get("bulletproofPlus") {
rct["bulletproofPlus"] = bp.clone();
}
if let Some(clsags) = prunable.get("CLSAGs") {
rct["CLSAGs"] = clsags.clone();
}
if let Some(tclsags) = prunable.get("TCLSAGs") {
rct["TCLSAGs"] = tclsags.clone();
}
if let Some(pseudo_outs) = prunable.get("pseudoOuts") {
rct["pseudoOuts"] = pseudo_outs.clone();
}
}
Err(e) => {
rct["prunable_parse_error"] = json!(e);
}
}
}
Ok(rct)
}
// ─── ZK Proof (3 x 32 bytes = 96 bytes) ─────────────────────────────────────
fn parse_zk_proof(c: &mut Cursor) -> Result<Value, String> {
let r = to_hex(c.read_bytes(32)?);
let z1 = to_hex(c.read_bytes(32)?);
let z2 = to_hex(c.read_bytes(32)?);
Ok(json!({ "R": r, "z1": z1, "z2": z2 }))
}
// ─── salvium_data_t ──────────────────────────────────────────────────────────
fn parse_salvium_data(c: &mut Cursor) -> Result<Value, String> {
let salvium_data_type = c.read_varint()?;
let pr_proof = parse_zk_proof(c)?;
let sa_proof = parse_zk_proof(c)?;
let mut result = json!({
"salvium_data_type": salvium_data_type,
"pr_proof": pr_proof,
"sa_proof": sa_proof
});
// SalviumZeroAudit (type 1)
if salvium_data_type == 1 {
let cz_proof = parse_zk_proof(c)?;
result["cz_proof"] = cz_proof;
// input_verification_data
let input_count = c.read_varint()? as usize;
let mut ivd = Vec::with_capacity(input_count);
for _ in 0..input_count {
let a_r = to_hex(c.read_bytes(32)?);
let amount = c.read_varint()?;
let idx = c.read_varint()?;
let origin_tx_type = c.read_varint()? as u8;
let mut item = json!({
"aR": a_r,
"amount": amount.to_string(),
"i": idx,
"origin_tx_type": origin_tx_type
});
if origin_tx_type != 0 {
item["aR_stake"] = json!(to_hex(c.read_bytes(32)?));
item["i_stake"] = json!(c.read_u64_le()?);
}
ivd.push(item);
}
result["input_verification_data"] = json!(ivd);
// spend_pubkey
result["spend_pubkey"] = json!(to_hex(c.read_bytes(32)?));
// enc_view_privkey_str
result["enc_view_privkey_str"] = json!(c.read_string()?);
}
Ok(result)
}
// ─── RCT Prunable Section ────────────────────────────────────────────────────
fn parse_rct_sig_prunable(
c: &mut Cursor,
rct_type: u8,
input_count: usize,
_output_count: usize,
mixin: usize,
) -> Result<Value, String> {
let mut result = json!({});
// BulletproofPlus
if rct_type >= RCT_TYPE_BULLETPROOF_PLUS {
let nbp = c.read_varint()? as usize;
if nbp > 1000 {
return Err(format!("Invalid bulletproofPlus count: {nbp}"));
}
let mut bp_proofs = Vec::with_capacity(nbp);
for _ in 0..nbp {
let a = to_hex(c.read_bytes(32)?);
let a1 = to_hex(c.read_bytes(32)?);
let b = to_hex(c.read_bytes(32)?);
let r1 = to_hex(c.read_bytes(32)?);
let s1 = to_hex(c.read_bytes(32)?);
let d1 = to_hex(c.read_bytes(32)?);
// L array (varint count)
let l_count = c.read_varint()? as usize;
if l_count > 64 {
return Err(format!("Invalid L array count: {l_count}"));
}
let mut l_arr = Vec::with_capacity(l_count);
for _ in 0..l_count {
l_arr.push(json!(to_hex(c.read_bytes(32)?)));
}
// R array (its own varint count)
let r_count = c.read_varint()? as usize;
if r_count > 64 {
return Err(format!("Invalid R array count: {r_count}"));
}
let mut r_arr = Vec::with_capacity(r_count);
for _ in 0..r_count {
r_arr.push(json!(to_hex(c.read_bytes(32)?)));
}
bp_proofs.push(json!({
"A": a, "A1": a1, "B": b,
"r1": r1, "s1": s1, "d1": d1,
"L": l_arr, "R": r_arr
}));
}
result["bulletproofPlus"] = json!(bp_proofs);
}
// CLSAGs / TCLSAGs
let ring_size = mixin + 1;
if rct_type == RCT_TYPE_SALVIUM_ONE {
// TCLSAGs
let mut tclsags = Vec::with_capacity(input_count);
for _ in 0..input_count {
let mut sx = Vec::with_capacity(ring_size);
for _ in 0..ring_size {
sx.push(json!(to_hex(c.read_bytes(32)?)));
}
let mut sy = Vec::with_capacity(ring_size);
for _ in 0..ring_size {
sy.push(json!(to_hex(c.read_bytes(32)?)));
}
let c1 = to_hex(c.read_bytes(32)?);
let d = to_hex(c.read_bytes(32)?);
tclsags.push(json!({ "sx": sx, "sy": sy, "c1": c1, "D": d }));
}
result["TCLSAGs"] = json!(tclsags);
} else if rct_type >= RCT_TYPE_CLSAG {
// CLSAGs
let mut clsags = Vec::with_capacity(input_count);
for _ in 0..input_count {
let mut s = Vec::with_capacity(ring_size);
for _ in 0..ring_size {
s.push(json!(to_hex(c.read_bytes(32)?)));
}
let c1 = to_hex(c.read_bytes(32)?);
let d = to_hex(c.read_bytes(32)?);
clsags.push(json!({ "s": s, "c1": c1, "D": d }));
}
result["CLSAGs"] = json!(clsags);
}
// pseudoOuts
if rct_type >= RCT_TYPE_BULLETPROOF_PLUS {
let mut pseudo_outs = Vec::with_capacity(input_count);
for _ in 0..input_count {
pseudo_outs.push(json!(to_hex(c.read_bytes(32)?)));
}
result["pseudoOuts"] = json!(pseudo_outs);
}
Ok(result)
}
// ─── Pricing Record Parsing ──────────────────────────────────────────────────
fn parse_pricing_record(c: &mut Cursor) -> Result<(Value, usize), String> {
let start = c.offset;
let pr_version = c.read_varint()?;
let height = c.read_varint()?;
// supply_data { sal, vsd }
let sal = c.read_varint()?;
let vsd = c.read_varint()?;
// assets vector
let assets_count = c.read_varint()? as usize;
let mut assets = Vec::with_capacity(assets_count);
for _ in 0..assets_count {
let asset_type = c.read_string()?;
let spot_price = c.read_varint()?;
let ma_price = c.read_varint()?;
assets.push(json!({
"assetType": asset_type,
"spotPrice": spot_price.to_string(),
"maPrice": ma_price.to_string()
}));
}
let timestamp = c.read_varint()?;
// signature (vector of bytes)
let sig_len = c.read_varint()? as usize;
let signature = if sig_len > 0 {
to_hex(c.read_bytes(sig_len)?)
} else {
String::new()
};
let bytes_read = c.offset - start;
Ok((
json!({
"prVersion": pr_version,
"height": height,
"supply": { "sal": sal.to_string(), "vsd": vsd.to_string() },
"assets": assets,
"timestamp": timestamp,
"signature": signature
}),
bytes_read,
))
}
// ─── Block Parsing ───────────────────────────────────────────────────────────
/// Parse a complete block from raw bytes to JSON string.
pub fn parse_block(data: &[u8]) -> Result<String, String> {
let mut c = Cursor::new(data);
// Block header
let major_version = c.read_varint()?;
let minor_version = c.read_varint()?;
let timestamp = c.read_varint()?;
let prev_id = to_hex(c.read_bytes(32)?);
let nonce = c.read_u32_le()?;
// Pricing record
let pricing_record = if major_version >= HF_VERSION_ENABLE_ORACLE {
let (pr, _) = parse_pricing_record(&mut c)?;
pr
} else {
Value::Null
};
// Miner TX
let miner_tx = parse_transaction_inner(&mut c)?;
// Protocol TX
let protocol_tx = parse_transaction_inner(&mut c)?;
// TX hashes
let tx_hash_count = c.read_varint()? as usize;
let mut tx_hashes = Vec::with_capacity(tx_hash_count);
for _ in 0..tx_hash_count {
tx_hashes.push(json!(to_hex(c.read_bytes(32)?)));
}
let result = json!({
"header": {
"majorVersion": major_version,
"minorVersion": minor_version,
"timestamp": timestamp,
"prevId": prev_id,
"nonce": nonce,
"pricingRecord": pricing_record
},
"minerTx": miner_tx,
"protocolTx": protocol_tx,
"txHashes": tx_hashes,
"_bytesRead": c.offset
});
serde_json::to_string(&result).map_err(|e| format!("JSON serialization error: {e}"))
}
// ─── Tests ───────────────────────────────────────────────────────────────────
/// Test helper functions exposed for roundtrip tests in tx_serialize.
#[cfg(test)]
pub(crate) mod tests_helper {
use crate::tx_constants::*;
use crate::tx_format::encode_varint;
pub fn build_minimal_coinbase_tx() -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&encode_varint(2));
data.extend_from_slice(&encode_varint(50));
data.extend_from_slice(&encode_varint(1));
data.push(TXIN_GEN);
data.extend_from_slice(&encode_varint(42));
data.extend_from_slice(&encode_varint(1));
data.extend_from_slice(&encode_varint(1000));
data.push(TXOUT_TAGGED_KEY);
data.extend_from_slice(&[0xAA; 32]);
data.extend_from_slice(&encode_varint(3));
data.extend_from_slice(b"SAL");
data.extend_from_slice(&encode_varint(0));
data.push(0x42);
data.extend_from_slice(&encode_varint(0));
data.extend_from_slice(&encode_varint(TX_TYPE_MINER as u64));
data.extend_from_slice(&encode_varint(0));
data.push(RCT_TYPE_NULL);
data
}
pub fn build_minimal_transfer_tx() -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&encode_varint(2));
data.extend_from_slice(&encode_varint(0));
data.extend_from_slice(&encode_varint(1));
data.push(TXIN_KEY);
data.extend_from_slice(&encode_varint(0));
data.extend_from_slice(&encode_varint(3));
data.extend_from_slice(b"SAL");
data.extend_from_slice(&encode_varint(16));
for i in 0u64..16 {
data.extend_from_slice(&encode_varint(i * 100));
}
data.extend_from_slice(&[0xBB; 32]);
data.extend_from_slice(&encode_varint(1));
data.extend_from_slice(&encode_varint(0));
data.push(TXOUT_TAGGED_KEY);
data.extend_from_slice(&[0xCC; 32]);
data.extend_from_slice(&encode_varint(3));
data.extend_from_slice(b"SAL");
data.extend_from_slice(&encode_varint(0));
data.push(0x99);
data.extend_from_slice(&encode_varint(33));
data.push(0x01);
data.extend_from_slice(&[0xDD; 32]);
data.extend_from_slice(&encode_varint(TX_TYPE_TRANSFER as u64));
data.extend_from_slice(&encode_varint(0));
data.extend_from_slice(&[0x11; 32]);
data.extend_from_slice(&[0x22; 32]);
data.extend_from_slice(&encode_varint(3));
data.extend_from_slice(b"SAL");
data.extend_from_slice(&encode_varint(3));
data.extend_from_slice(b"SAL");
data.extend_from_slice(&encode_varint(0));
data.push(RCT_TYPE_SALVIUM_ZERO);
data.extend_from_slice(&encode_varint(50000));
data.extend_from_slice(&[0xEE; 8]);
data.extend_from_slice(&[0xFF; 32]);
data.extend_from_slice(&[0x33; 32]);
data.extend_from_slice(&encode_varint(0));
data.extend_from_slice(&[0x44; 96]);
data.extend_from_slice(&[0x55; 96]);
data.extend_from_slice(&encode_varint(1));
data.extend_from_slice(&[0x66; 32]);
data.extend_from_slice(&[0x67; 32]);
data.extend_from_slice(&[0x68; 32]);
data.extend_from_slice(&[0x69; 32]);
data.extend_from_slice(&[0x6A; 32]);
data.extend_from_slice(&[0x6B; 32]);
data.extend_from_slice(&encode_varint(2));
data.extend_from_slice(&[0x6C; 32]);
data.extend_from_slice(&[0x6D; 32]);
data.extend_from_slice(&encode_varint(2));
data.extend_from_slice(&[0x6E; 32]);
data.extend_from_slice(&[0x6F; 32]);
for _ in 0..16 {
data.extend_from_slice(&[0x70; 32]);
}
data.extend_from_slice(&[0x71; 32]);
data.extend_from_slice(&[0x72; 32]);
data.extend_from_slice(&[0x73; 32]);
data
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tx_format::encode_varint;
/// Build a minimal coinbase TX (v2, 1 gen input, 1 tagged_key output, RCT Null).
fn build_minimal_coinbase_tx() -> Vec<u8> {
let mut data = Vec::new();
// version = 2
data.extend_from_slice(&encode_varint(2));
// unlock_time = 50
data.extend_from_slice(&encode_varint(50));
// 1 input (gen)
data.extend_from_slice(&encode_varint(1));
data.push(TXIN_GEN); // input type
data.extend_from_slice(&encode_varint(42)); // height
// 1 output (tagged_key)
data.extend_from_slice(&encode_varint(1));
data.extend_from_slice(&encode_varint(1000)); // amount
data.push(TXOUT_TAGGED_KEY);
data.extend_from_slice(&[0xAA; 32]); // key
data.extend_from_slice(&encode_varint(3)); // asset type len
data.extend_from_slice(b"SAL");
data.extend_from_slice(&encode_varint(0)); // unlock_time
data.push(0x42); // view_tag
// extra (empty)
data.extend_from_slice(&encode_varint(0));
// tx_type = MINER (1)
data.extend_from_slice(&encode_varint(TX_TYPE_MINER as u64));
// amount_burnt = 0
data.extend_from_slice(&encode_varint(0));
// RCT type = Null
data.push(RCT_TYPE_NULL);
data
}
#[test]
fn test_parse_minimal_coinbase() {
let data = build_minimal_coinbase_tx();
let json_str = parse_transaction(&data).unwrap();
let parsed: Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["prefix"]["version"], 2);
assert_eq!(parsed["prefix"]["unlockTime"], 50);
assert_eq!(parsed["prefix"]["txType"], TX_TYPE_MINER);
assert_eq!(parsed["prefix"]["vin"][0]["type"], TXIN_GEN as u64);
assert_eq!(parsed["prefix"]["vin"][0]["height"], 42);
assert_eq!(parsed["prefix"]["vout"][0]["type"], TXOUT_TAGGED_KEY as u64);
assert_eq!(parsed["prefix"]["vout"][0]["viewTag"], 0x42);
assert_eq!(parsed["rct"]["type"], RCT_TYPE_NULL);
}
/// Build a minimal transfer TX (v2, 1 key input, 1 tagged_key output, RCT BP+, no prunable).
fn build_minimal_transfer_tx() -> Vec<u8> {
let mut data = Vec::new();
// version = 2
data.extend_from_slice(&encode_varint(2));
// unlock_time = 0
data.extend_from_slice(&encode_varint(0));
// 1 input (key)
data.extend_from_slice(&encode_varint(1));
data.push(TXIN_KEY);
data.extend_from_slice(&encode_varint(0)); // amount
data.extend_from_slice(&encode_varint(3)); // asset_type len
data.extend_from_slice(b"SAL");
data.extend_from_slice(&encode_varint(16)); // 16 key offsets
for i in 0u64..16 {
data.extend_from_slice(&encode_varint(i * 100));
}
data.extend_from_slice(&[0xBB; 32]); // key image
// 1 output (tagged_key)
data.extend_from_slice(&encode_varint(1));
data.extend_from_slice(&encode_varint(0)); // amount
data.push(TXOUT_TAGGED_KEY);
data.extend_from_slice(&[0xCC; 32]); // key
data.extend_from_slice(&encode_varint(3)); // asset_type len
data.extend_from_slice(b"SAL");
data.extend_from_slice(&encode_varint(0)); // unlock_time
data.push(0x99); // view_tag
// extra: tag 0x01 + 32-byte pubkey
data.extend_from_slice(&encode_varint(33));
data.push(0x01);
data.extend_from_slice(&[0xDD; 32]);
// tx_type = TRANSFER (3)
data.extend_from_slice(&encode_varint(TX_TYPE_TRANSFER as u64));
// amount_burnt = 0
data.extend_from_slice(&encode_varint(0));
// return_address (32 bytes)
data.extend_from_slice(&[0x11; 32]);
// return_pubkey (32 bytes)
data.extend_from_slice(&[0x22; 32]);
// source_asset_type
data.extend_from_slice(&encode_varint(3));
data.extend_from_slice(b"SAL");
// destination_asset_type
data.extend_from_slice(&encode_varint(3));
data.extend_from_slice(b"SAL");
// amount_slippage_limit
data.extend_from_slice(&encode_varint(0));
// RCT type = SalviumZero (8)
data.push(RCT_TYPE_SALVIUM_ZERO);
// fee
data.extend_from_slice(&encode_varint(50000));
// ecdhInfo: 1 output x 8 bytes
data.extend_from_slice(&[0xEE; 8]);
// outPk: 1 output x 32 bytes
data.extend_from_slice(&[0xFF; 32]);
// p_r: 32 bytes
data.extend_from_slice(&[0x33; 32]);
// salvium_data (type 0 = SalviumZero basic)
data.extend_from_slice(&encode_varint(0)); // salvium_data_type
data.extend_from_slice(&[0x44; 96]); // pr_proof
data.extend_from_slice(&[0x55; 96]); // sa_proof
// prunable: 1 BP+ proof (minimal)
data.extend_from_slice(&encode_varint(1)); // nbp
data.extend_from_slice(&[0x66; 32]); // A
data.extend_from_slice(&[0x67; 32]); // A1
data.extend_from_slice(&[0x68; 32]); // B
data.extend_from_slice(&[0x69; 32]); // r1
data.extend_from_slice(&[0x6A; 32]); // s1
data.extend_from_slice(&[0x6B; 32]); // d1
data.extend_from_slice(&encode_varint(2)); // L count
data.extend_from_slice(&[0x6C; 32]); // L[0]
data.extend_from_slice(&[0x6D; 32]); // L[1]
data.extend_from_slice(&encode_varint(2)); // R count
data.extend_from_slice(&[0x6E; 32]); // R[0]
data.extend_from_slice(&[0x6F; 32]); // R[1]
// CLSAGs (1 input, ring_size = 16)
for _ in 0..16 {
data.extend_from_slice(&[0x70; 32]); // s[j]
}
data.extend_from_slice(&[0x71; 32]); // c1
data.extend_from_slice(&[0x72; 32]); // D
// pseudoOuts (1 input)
data.extend_from_slice(&[0x73; 32]);
data
}
#[test]
fn test_parse_minimal_transfer() {
let data = build_minimal_transfer_tx();
let json_str = parse_transaction(&data).unwrap();
let parsed: Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["prefix"]["version"], 2);
assert_eq!(parsed["prefix"]["txType"], TX_TYPE_TRANSFER);
assert_eq!(parsed["rct"]["type"], RCT_TYPE_SALVIUM_ZERO);
assert_eq!(parsed["rct"]["txnFee"], "50000");
// BP+ should have 1 proof with 2 L and 2 R
let bp = &parsed["rct"]["bulletproofPlus"];
assert_eq!(bp.as_array().unwrap().len(), 1);
assert_eq!(bp[0]["L"].as_array().unwrap().len(), 2);
assert_eq!(bp[0]["R"].as_array().unwrap().len(), 2);
// CLSAGs
let clsags = &parsed["rct"]["CLSAGs"];
assert_eq!(clsags.as_array().unwrap().len(), 1);
assert_eq!(clsags[0]["s"].as_array().unwrap().len(), 16);
// pseudoOuts
assert_eq!(parsed["rct"]["pseudoOuts"].as_array().unwrap().len(), 1);
}
#[test]
fn test_parse_carrot_v1_output() {
let mut data = Vec::new();
// version = 4
data.extend_from_slice(&encode_varint(4));
data.extend_from_slice(&encode_varint(0)); // unlock_time
// 0 inputs
data.extend_from_slice(&encode_varint(0));
// 1 output (CARROT_V1)
data.extend_from_slice(&encode_varint(1));
data.extend_from_slice(&encode_varint(0)); // amount
data.push(TXOUT_CARROT_V1);
data.extend_from_slice(&[0xAA; 32]); // key
data.extend_from_slice(&encode_varint(4)); // asset type len
data.extend_from_slice(b"SAL1");
data.extend_from_slice(&[0xBB; 3]); // view_tag (3 bytes)
data.extend_from_slice(&[0xCC; 16]); // encrypted_janus_anchor
// extra (empty)
data.extend_from_slice(&encode_varint(0));
// tx_type = UNSET
data.extend_from_slice(&encode_varint(0));
// RCT type = Null
data.push(RCT_TYPE_NULL);
let json_str = parse_transaction(&data).unwrap();
let parsed: Value = serde_json::from_str(&json_str).unwrap();
let out = &parsed["prefix"]["vout"][0];
assert_eq!(out["type"], TXOUT_CARROT_V1 as u64);
assert_eq!(out["assetType"], "SAL1");
assert_eq!(out["viewTag"], hex::encode([0xBB; 3]));
assert_eq!(out["encryptedJanusAnchor"], hex::encode([0xCC; 16]));
}
}
+516
View File
@@ -0,0 +1,516 @@
//! Full transaction JSON-to-binary serializer.
//!
//! Mirrors `tx_parse.rs` in reverse — reads a JSON string describing a
//! Salvium transaction and writes the corresponding binary representation.
//!
//! The JSON format matches the output of `tx_parse::parse_transaction()`.
use crate::tx_constants::*;
use crate::tx_format::encode_varint;
use serde_json::Value;
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn hex_to_bytes(hex: &str) -> Result<Vec<u8>, String> {
hex::decode(hex).map_err(|e| format!("Invalid hex string '{}': {}", hex, e))
}
fn get_str<'a>(v: &'a Value, key: &str) -> &'a str {
v.get(key).and_then(|v| v.as_str()).unwrap_or("")
}
fn get_u64(v: &Value, key: &str) -> u64 {
match v.get(key) {
Some(Value::Number(n)) => n.as_u64().unwrap_or(0),
Some(Value::String(s)) => s.parse::<u64>().unwrap_or(0),
_ => 0,
}
}
fn get_u8(v: &Value, key: &str) -> u8 {
get_u64(v, key) as u8
}
fn get_array<'a>(v: &'a Value, key: &str) -> &'a [Value] {
v.get(key)
.and_then(|v| v.as_array())
.map(|a| a.as_slice())
.unwrap_or(&[])
}
fn write_varint(buf: &mut Vec<u8>, val: u64) {
buf.extend_from_slice(&encode_varint(val));
}
fn write_hex_bytes(buf: &mut Vec<u8>, hex: &str) -> Result<(), String> {
buf.extend_from_slice(&hex_to_bytes(hex)?);
Ok(())
}
fn write_string(buf: &mut Vec<u8>, s: &str) {
let bytes = s.as_bytes();
write_varint(buf, bytes.len() as u64);
buf.extend_from_slice(bytes);
}
fn write_u64_le(buf: &mut Vec<u8>, val: u64) {
buf.extend_from_slice(&val.to_le_bytes());
}
// ─── Transaction Serialization ───────────────────────────────────────────────
/// Serialize a complete transaction from JSON string to binary bytes.
///
/// Input JSON format matches the output of `tx_parse::parse_transaction()`.
pub fn serialize_transaction(json_str: &str) -> Result<Vec<u8>, String> {
let tx: Value =
serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {e}"))?;
let mut buf = Vec::with_capacity(4096);
// 1. TX prefix
serialize_tx_prefix_inner(&tx, &mut buf)?;
// 2. RCT base
if let Some(rct) = tx.get("rct") {
serialize_rct_base(rct, &mut buf)?;
let rct_type = get_u8(rct, "type");
if rct_type != RCT_TYPE_NULL {
// 3. RCT prunable
serialize_rct_prunable(rct, rct_type, &mut buf)?;
}
}
Ok(buf)
}
/// Serialize just the transaction prefix from JSON to binary.
pub fn serialize_tx_prefix(json_str: &str) -> Result<Vec<u8>, String> {
let tx: Value =
serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {e}"))?;
let mut buf = Vec::with_capacity(2048);
serialize_tx_prefix_inner(&tx, &mut buf)?;
Ok(buf)
}
/// Serialize the RCT base section (type + fee + ecdhInfo + outPk + p_r + salvium_data).
pub fn serialize_rct_base(rct: &Value, buf: &mut Vec<u8>) -> Result<(), String> {
let rct_type = get_u8(rct, "type");
buf.push(rct_type);
if rct_type == RCT_TYPE_NULL {
return Ok(());
}
// Fee
let fee = get_u64(rct, "txnFee");
write_varint(buf, fee);
// ecdhInfo (8 bytes per output)
for item in get_array(rct, "ecdhInfo") {
let amount_hex = get_str(item, "amount");
write_hex_bytes(buf, amount_hex)?;
}
// outPk (32 bytes per output)
for item in get_array(rct, "outPk") {
let hex = item.as_str().unwrap_or("");
write_hex_bytes(buf, hex)?;
}
// p_r (32 bytes)
let p_r = get_str(rct, "p_r");
if !p_r.is_empty() {
write_hex_bytes(buf, p_r)?;
} else {
buf.extend_from_slice(&[0u8; 32]);
}
// salvium_data
match rct_type {
RCT_TYPE_SALVIUM_ZERO | RCT_TYPE_SALVIUM_ONE => {
if let Some(sd) = rct.get("salvium_data") {
serialize_salvium_data(sd, buf)?;
}
}
RCT_TYPE_FULL_PROOFS => {
if let Some(sd) = rct.get("salvium_data") {
serialize_zk_proof(sd.get("pr_proof").unwrap_or(&Value::Null), buf)?;
serialize_zk_proof(sd.get("sa_proof").unwrap_or(&Value::Null), buf)?;
}
}
_ => {}
}
Ok(())
}
// ─── TX Prefix ───────────────────────────────────────────────────────────────
fn serialize_tx_prefix_inner(tx: &Value, buf: &mut Vec<u8>) -> Result<(), String> {
// The JSON can have prefix as a nested object or be flat
let prefix = tx.get("prefix").unwrap_or(tx);
let version = get_u64(prefix, "version");
write_varint(buf, version);
write_varint(buf, get_u64(prefix, "unlockTime"));
// Inputs
let vin = get_array(prefix, "vin");
write_varint(buf, vin.len() as u64);
for input in vin {
let input_type = get_u8(input, "type");
match input_type {
TXIN_GEN => {
buf.push(TXIN_GEN);
write_varint(buf, get_u64(input, "height"));
}
TXIN_KEY => {
buf.push(TXIN_KEY);
write_varint(buf, get_u64(input, "amount"));
write_string(buf, get_str(input, "assetType"));
let offsets = get_array(input, "keyOffsets");
write_varint(buf, offsets.len() as u64);
for o in offsets {
write_varint(buf, o.as_u64().unwrap_or(0));
}
write_hex_bytes(buf, get_str(input, "keyImage"))?;
}
_ => return Err(format!("Unknown input type: {input_type}")),
}
}
// Outputs
let vout = get_array(prefix, "vout");
write_varint(buf, vout.len() as u64);
for output in vout {
write_varint(buf, get_u64(output, "amount"));
let output_type = get_u8(output, "type");
match output_type {
TXOUT_KEY => {
buf.push(TXOUT_KEY);
write_hex_bytes(buf, get_str(output, "key"))?;
write_string(buf, get_str(output, "assetType"));
write_varint(buf, get_u64(output, "unlockTime"));
}
TXOUT_TAGGED_KEY => {
buf.push(TXOUT_TAGGED_KEY);
write_hex_bytes(buf, get_str(output, "key"))?;
write_string(buf, get_str(output, "assetType"));
write_varint(buf, get_u64(output, "unlockTime"));
buf.push(get_u8(output, "viewTag"));
}
TXOUT_CARROT_V1 => {
buf.push(TXOUT_CARROT_V1);
write_hex_bytes(buf, get_str(output, "key"))?;
write_string(buf, get_str(output, "assetType"));
write_hex_bytes(buf, get_str(output, "viewTag"))?;
write_hex_bytes(buf, get_str(output, "encryptedJanusAnchor"))?;
}
_ => return Err(format!("Unknown output type: {output_type}")),
}
}
// Extra
if let Some(extra) = prefix.get("extra") {
// Extra is already a parsed array — serialize via tx_format
let extra_bytes = serialize_extra_from_parsed(extra)?;
write_varint(buf, extra_bytes.len() as u64);
buf.extend_from_slice(&extra_bytes);
} else {
write_varint(buf, 0);
}
// Salvium-specific prefix fields
let tx_type = get_u8(prefix, "txType");
write_varint(buf, tx_type as u64);
if tx_type != TX_TYPE_UNSET && tx_type != TX_TYPE_PROTOCOL {
write_varint(buf, get_u64(prefix, "amount_burnt"));
if tx_type != TX_TYPE_MINER {
if tx_type == TX_TYPE_TRANSFER && version >= 3 {
// return_address_list
let list = get_array(prefix, "return_address_list");
write_varint(buf, list.len() as u64);
for addr in list {
write_hex_bytes(buf, addr.as_str().unwrap_or(""))?;
}
// return_address_change_mask
let mask = get_str(prefix, "return_address_change_mask");
if !mask.is_empty() {
let mask_bytes = hex_to_bytes(mask)?;
write_varint(buf, mask_bytes.len() as u64);
buf.extend_from_slice(&mask_bytes);
} else {
write_varint(buf, 0);
}
} else if tx_type == TX_TYPE_STAKE && version >= 4 {
// protocol_tx_data
if let Some(ptx) = prefix.get("protocol_tx_data") {
write_varint(buf, get_u64(ptx, "version"));
write_hex_bytes(buf, get_str(ptx, "return_address"))?;
write_hex_bytes(buf, get_str(ptx, "return_pubkey"))?;
write_hex_bytes(buf, get_str(ptx, "return_view_tag"))?;
write_hex_bytes(buf, get_str(ptx, "return_anchor_enc"))?;
}
} else {
// Legacy: return_address + return_pubkey
let ra = get_str(prefix, "return_address");
if !ra.is_empty() {
write_hex_bytes(buf, ra)?;
} else {
buf.extend_from_slice(&[0u8; 32]);
}
let rp = get_str(prefix, "return_pubkey");
if !rp.is_empty() {
write_hex_bytes(buf, rp)?;
} else {
buf.extend_from_slice(&[0u8; 32]);
}
}
// source_asset_type
write_string(buf, get_str(prefix, "source_asset_type"));
// destination_asset_type
write_string(buf, get_str(prefix, "destination_asset_type"));
// amount_slippage_limit
write_varint(buf, get_u64(prefix, "amount_slippage_limit"));
}
}
Ok(())
}
// ─── Extra Serialization (from parsed JSON array) ────────────────────────────
fn serialize_extra_from_parsed(extra: &Value) -> Result<Vec<u8>, String> {
let entries = match extra.as_array() {
Some(a) => a,
None => return Ok(Vec::new()),
};
let mut buf = Vec::new();
for entry in entries {
let tag = get_u8(entry, "type");
match tag {
0x00 => {
buf.push(0x00);
}
0x01 => {
buf.push(0x01);
let key = get_str(entry, "key");
if !key.is_empty() {
write_hex_bytes(&mut buf, key)?;
}
}
0x02 => {
// Nonce: reconstruct from paymentId or raw
let pid_type = get_str(entry, "paymentIdType");
if pid_type == "encrypted" {
buf.push(0x02);
buf.push(9); // nonce length
buf.push(0x01); // encrypted PID tag
write_hex_bytes(&mut buf, get_str(entry, "paymentId"))?;
} else if pid_type == "unencrypted" {
buf.push(0x02);
buf.push(33); // nonce length
buf.push(0x00); // unencrypted PID tag
write_hex_bytes(&mut buf, get_str(entry, "paymentId"))?;
} else {
let raw = get_str(entry, "raw");
if !raw.is_empty() {
buf.push(0x02);
let raw_bytes = hex_to_bytes(raw)?;
buf.push(raw_bytes.len() as u8);
buf.extend_from_slice(&raw_bytes);
}
}
}
0x04 => {
buf.push(0x04);
let keys = get_array(entry, "keys");
buf.push(keys.len() as u8);
for k in keys {
write_hex_bytes(&mut buf, k.as_str().unwrap_or(""))?;
}
}
_ => {
// Unknown tags: tag + varint size + data
buf.push(tag);
let data = get_str(entry, "data");
if !data.is_empty() {
let data_bytes = hex_to_bytes(data)?;
write_varint(&mut buf, data_bytes.len() as u64);
buf.extend_from_slice(&data_bytes);
}
}
}
}
Ok(buf)
}
// ─── ZK Proof Serialization ─────────────────────────────────────────────────
fn serialize_zk_proof(proof: &Value, buf: &mut Vec<u8>) -> Result<(), String> {
let r = get_str(proof, "R");
let z1 = get_str(proof, "z1");
let z2 = get_str(proof, "z2");
if r.is_empty() {
buf.extend_from_slice(&[0u8; 96]);
} else {
write_hex_bytes(buf, r)?;
write_hex_bytes(buf, z1)?;
write_hex_bytes(buf, z2)?;
}
Ok(())
}
// ─── salvium_data_t Serialization ────────────────────────────────────────────
fn serialize_salvium_data(sd: &Value, buf: &mut Vec<u8>) -> Result<(), String> {
let data_type = get_u64(sd, "salvium_data_type");
write_varint(buf, data_type);
serialize_zk_proof(sd.get("pr_proof").unwrap_or(&Value::Null), buf)?;
serialize_zk_proof(sd.get("sa_proof").unwrap_or(&Value::Null), buf)?;
// SalviumZeroAudit (type 1)
if data_type == 1 {
serialize_zk_proof(sd.get("cz_proof").unwrap_or(&Value::Null), buf)?;
// input_verification_data
let ivd = get_array(sd, "input_verification_data");
write_varint(buf, ivd.len() as u64);
for item in ivd {
write_hex_bytes(buf, get_str(item, "aR"))?;
write_varint(buf, get_u64(item, "amount"));
write_varint(buf, get_u64(item, "i"));
let origin = get_u8(item, "origin_tx_type");
write_varint(buf, origin as u64);
if origin != 0 {
write_hex_bytes(buf, get_str(item, "aR_stake"))?;
write_u64_le(buf, get_u64(item, "i_stake"));
}
}
// spend_pubkey
let spk = get_str(sd, "spend_pubkey");
if !spk.is_empty() {
write_hex_bytes(buf, spk)?;
} else {
buf.extend_from_slice(&[0u8; 32]);
}
// enc_view_privkey_str
write_string(buf, get_str(sd, "enc_view_privkey_str"));
}
Ok(())
}
// ─── RCT Prunable Serialization ─────────────────────────────────────────────
fn serialize_rct_prunable(rct: &Value, rct_type: u8, buf: &mut Vec<u8>) -> Result<(), String> {
// BulletproofPlus
if rct_type >= RCT_TYPE_BULLETPROOF_PLUS {
let proofs = get_array(rct, "bulletproofPlus");
write_varint(buf, proofs.len() as u64);
for proof in proofs {
write_hex_bytes(buf, get_str(proof, "A"))?;
write_hex_bytes(buf, get_str(proof, "A1"))?;
write_hex_bytes(buf, get_str(proof, "B"))?;
write_hex_bytes(buf, get_str(proof, "r1"))?;
write_hex_bytes(buf, get_str(proof, "s1"))?;
write_hex_bytes(buf, get_str(proof, "d1"))?;
// L array
let l_arr = get_array(proof, "L");
write_varint(buf, l_arr.len() as u64);
for l in l_arr {
write_hex_bytes(buf, l.as_str().unwrap_or(""))?;
}
// R array
let r_arr = get_array(proof, "R");
write_varint(buf, r_arr.len() as u64);
for r in r_arr {
write_hex_bytes(buf, r.as_str().unwrap_or(""))?;
}
}
}
// Ring signatures
if rct_type == RCT_TYPE_SALVIUM_ONE {
// TCLSAGs
for sig in get_array(rct, "TCLSAGs") {
for s in get_array(sig, "sx") {
write_hex_bytes(buf, s.as_str().unwrap_or(""))?;
}
for s in get_array(sig, "sy") {
write_hex_bytes(buf, s.as_str().unwrap_or(""))?;
}
write_hex_bytes(buf, get_str(sig, "c1"))?;
write_hex_bytes(buf, get_str(sig, "D"))?;
}
} else if rct_type >= RCT_TYPE_CLSAG {
// CLSAGs
for sig in get_array(rct, "CLSAGs") {
for s in get_array(sig, "s") {
write_hex_bytes(buf, s.as_str().unwrap_or(""))?;
}
write_hex_bytes(buf, get_str(sig, "c1"))?;
write_hex_bytes(buf, get_str(sig, "D"))?;
}
}
// pseudoOuts
if rct_type >= RCT_TYPE_BULLETPROOF_PLUS {
for po in get_array(rct, "pseudoOuts") {
write_hex_bytes(buf, po.as_str().unwrap_or(""))?;
}
}
Ok(())
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::tx_parse;
#[test]
fn test_roundtrip_coinbase() {
// Build a minimal coinbase TX
let original = crate::tx_parse::tests_helper::build_minimal_coinbase_tx();
// Parse → JSON
let json = tx_parse::parse_transaction(&original).unwrap();
// Serialize JSON → binary
let reserialized = serialize_transaction(&json).unwrap();
assert_eq!(
hex::encode(&original),
hex::encode(&reserialized),
"Coinbase TX roundtrip failed"
);
}
#[test]
fn test_roundtrip_transfer() {
let original = crate::tx_parse::tests_helper::build_minimal_transfer_tx();
let json = tx_parse::parse_transaction(&original).unwrap();
let reserialized = serialize_transaction(&json).unwrap();
assert_eq!(
hex::encode(&original),
hex::encode(&reserialized),
"Transfer TX roundtrip failed"
);
}
}
+16
View File
@@ -299,6 +299,22 @@ impl Fe {
}
}
/// Convert Ed25519 compressed point to X25519 u-coordinate.
/// u = (1 + y) / (1 - y) mod p, where y is the Ed25519 y-coordinate.
pub(crate) fn edwards_to_montgomery_u(ed_point: &[u8; 32]) -> [u8; 32] {
// Extract y-coordinate (clear the sign bit in the high byte)
let mut y_bytes = *ed_point;
y_bytes[31] &= 0x7F;
let y = Fe::from_bytes(&y_bytes);
// u = (1 + y) / (1 - y)
let numerator = Fe::add(&Fe::ONE, &y).carry_reduce();
let denominator = Fe::sub(&Fe::ONE, &y).carry_reduce();
let inv_denom = Fe::invert(&denominator);
let u = Fe::mul(&numerator, &inv_denom);
u.to_bytes()
}
/// Montgomery ladder: compute scalar * u_point on Curve25519 (Montgomery form).
///
/// scalar: 32-byte little-endian scalar (already clamped by caller).
-1
View File
@@ -25,7 +25,6 @@
"./bulletproofs": "./src/bulletproofs_plus.js",
"./transaction": "./src/transaction.js",
"./scanning": "./src/scanning.js",
"./keyimage": "./src/keyimage.js",
"./mining": "./src/mining.js",
"./stratum": "./src/stratum/index.js",
"./wallet": "./src/wallet.js",
+138 -1076
View File
File diff suppressed because it is too large Load Diff
+3 -60
View File
@@ -18,7 +18,7 @@
import { hexToBytes, bytesToHex } from './address.js';
import {
blake2b, keccak256, scalarMultBase, scalarMultPoint, pointAddCompressed, hashToPoint,
commit as pedersenCommit, x25519ScalarMult,
commit as pedersenCommit, x25519ScalarMult, edwardsToMontgomeryU,
scReduce64, scAdd, scMul,
computeCarrotViewTagRust, decryptCarrotAmountRust,
deriveCarrotCommitmentMaskRust, recoverCarrotAddressSpendPubkeyRust,
@@ -29,67 +29,10 @@ import {
// X25519 Implementation (Montgomery Curve)
// ============================================================================
/**
* Convert ed25519 point (compressed) to X25519 (Montgomery u-coordinate)
* The Montgomery u-coordinate is: u = (1 + y) / (1 - y) mod p
* where y is the ed25519 y-coordinate
*
* @param {Uint8Array} edPoint - 32-byte ed25519 compressed point
* @returns {Uint8Array} 32-byte X25519 u-coordinate
*/
export function edwardsToMontgomeryU(edPoint) {
// Ed25519 compressed format: sign bit in MSB of last byte, y-coordinate in rest
// Extract y-coordinate
const p = 2n ** 255n - 19n;
let y = 0n;
for (let i = 0; i < 32; i++) {
y |= BigInt(edPoint[i]) << (8n * BigInt(i));
}
// Clear the sign bit
y &= (1n << 255n) - 1n;
// u = (1 + y) / (1 - y) mod p
const one = 1n;
const numerator = (one + y) % p;
const denominator = (p + one - y) % p;
// Compute modular inverse of denominator using Fermat's little theorem
// p is prime, so denominator^(p-2) = denominator^(-1) mod p
const invDenom = modPow(denominator, p - 2n, p);
const u = (numerator * invDenom) % p;
// Convert to bytes (little-endian)
const result = new Uint8Array(32);
let val = u;
for (let i = 0; i < 32; i++) {
result[i] = Number(val & 0xffn);
val >>= 8n;
}
return result;
}
/**
* Modular exponentiation: base^exp mod mod
*/
function modPow(base, exp, mod) {
let result = 1n;
base = base % mod;
while (exp > 0n) {
if (exp % 2n === 1n) {
result = (result * base) % mod;
}
exp = exp >> 1n;
base = (base * base) % mod;
}
return result;
}
// x25519ScalarMult is now imported from ./crypto/index.js (routed through the crypto provider).
// edwardsToMontgomeryU and x25519ScalarMult are imported from ./crypto/index.js (routed through the crypto provider).
// The JS backend has a pure-JS BigInt fallback; WASM/FFI backends use native Rust.
// Re-export for consumers that import from this module.
export { x25519ScalarMult };
export { x25519ScalarMult, edwardsToMontgomeryU };
/**
* Perform X25519 ECDH key exchange for CARROT scanning
+111 -32
View File
@@ -144,6 +144,7 @@ const FFI_SYMBOLS = {
// X25519
salvium_x25519_scalar_mult: { args: [ptr, ptr, ptr], returns: i32 },
salvium_edwards_to_montgomery_u: { args: [ptr, ptr], returns: i32 },
// Hash-to-point & key derivation
salvium_hash_to_point: { args: [ptr, usize, ptr], returns: i32 },
@@ -206,6 +207,11 @@ const FFI_SYMBOLS = {
salvium_serialize_tx_extra: { args: [ptr, usize, ptr, ptr], returns: i32 },
salvium_compute_tx_prefix_hash: { args: [ptr, usize, ptr], returns: i32 },
// Full transaction parsing & serialization
salvium_parse_transaction: { args: [ptr, usize, ptr, ptr], returns: i32 },
salvium_serialize_transaction: { args: [ptr, usize, ptr, ptr], returns: i32 },
salvium_parse_block: { args: [ptr, usize, ptr, ptr], returns: i32 },
// CARROT scanning
salvium_carrot_scan_output: { args: [ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr, usize, FFIType.u64, ptr, u32, ptr, ptr], returns: i32 },
salvium_carrot_scan_internal: { args: [ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr, usize, FFIType.u64, ptr, u32, ptr, ptr], returns: i32 },
@@ -223,6 +229,13 @@ export class FfiCryptoBackend {
constructor() {
this.name = 'ffi';
this.lib = null;
// Cached serialized subaddress maps to avoid re-marshaling on every scan call
this._cachedSubBuf = null;
this._cachedSubN = 0;
this._cachedSubMapRef = null; // WeakRef to detect map identity
this._cachedCarrotSubBuf = null;
this._cachedCarrotSubN = 0;
this._cachedCarrotSubMapRef = null;
}
async init() {
@@ -230,6 +243,40 @@ export class FfiCryptoBackend {
this.lib = dlopen(libPath, FFI_SYMBOLS);
}
/**
* Serialize a subaddress map to a flat buffer for FFI.
* Caches the result so repeated calls with the same Map skip serialization.
* Each entry: 32-byte key + 4-byte major LE + 4-byte minor LE = 40 bytes.
* @private
*/
_marshalSubaddressMap(subaddressMap, cacheSlot) {
if (!subaddressMap || subaddressMap.size === 0) {
return { buf: Buffer.alloc(0), n: 0 };
}
// Check cache: same Map object reference → reuse buffer
const refField = cacheSlot + 'Ref';
const bufField = cacheSlot + 'Buf';
const nField = cacheSlot + 'N';
if (this[refField] === subaddressMap && this[bufField]) {
return { buf: this[bufField], n: this[nField] };
}
// Serialize
const n = subaddressMap.size;
const buf = Buffer.alloc(n * 40);
let offset = 0;
for (const [hexKey, { major, minor }] of subaddressMap) {
const keyBytes = hexToBytes(hexKey);
buf.set(keyBytes, offset); offset += 32;
buf.writeUInt32LE(major, offset); offset += 4;
buf.writeUInt32LE(minor, offset); offset += 4;
}
// Cache
this[refField] = subaddressMap;
this[bufField] = buf;
this[nField] = n;
return { buf, n };
}
// ─── Hashing ──────────────────────────────────────────────────────────
keccak256(data) {
@@ -379,6 +426,13 @@ export class FfiCryptoBackend {
return new Uint8Array(out);
}
edwardsToMontgomeryU(point) {
const bp = ensureBuffer(point), out = Buffer.alloc(32);
const rc = this.lib.symbols.salvium_edwards_to_montgomery_u(bp, out);
if (rc !== 0) throw new Error('edwards_to_montgomery_u failed');
return new Uint8Array(out);
}
// ─── Hash-to-point & key derivation ───────────────────────────────────
hashToPoint(data) {
@@ -899,6 +953,59 @@ export class FfiCryptoBackend {
return new Uint8Array(out);
}
// ─── Full Transaction Parsing & Serialization ──────────────────────────────
/**
* Parse a complete transaction from raw bytes. Returns parsed JSON object or null.
*/
parseTransaction(data) {
const bData = ensureBuffer(data);
const outPtrBuf = Buffer.alloc(8);
const outLenBuf = Buffer.alloc(8);
const rc = this.lib.symbols.salvium_parse_transaction(bData, bData.length, outPtrBuf, outLenBuf);
if (rc !== 0) return null;
const resultPtr = Number(outPtrBuf.readBigUInt64LE(0));
const resultLen = Number(outLenBuf.readBigUInt64LE(0));
if (!resultPtr || !resultLen) return null;
const resultBuf = new Uint8Array(toArrayBuffer(resultPtr, 0, resultLen)).slice();
this.lib.symbols.salvium_storage_free_buf(resultPtr, resultLen);
return JSON.parse(new TextDecoder().decode(resultBuf));
}
/**
* Serialize a transaction from JS object to raw bytes. Returns Uint8Array or null.
*/
serializeTransaction(txObj) {
const bJson = Buffer.from(JSON.stringify(txObj), 'utf8');
const outPtrBuf = Buffer.alloc(8);
const outLenBuf = Buffer.alloc(8);
const rc = this.lib.symbols.salvium_serialize_transaction(bJson, bJson.length, outPtrBuf, outLenBuf);
if (rc !== 0) return null;
const resultPtr = Number(outPtrBuf.readBigUInt64LE(0));
const resultLen = Number(outLenBuf.readBigUInt64LE(0));
if (!resultPtr) return new Uint8Array(0);
const result = new Uint8Array(toArrayBuffer(resultPtr, 0, resultLen)).slice();
this.lib.symbols.salvium_storage_free_buf(resultPtr, resultLen);
return result;
}
/**
* Parse a complete block from raw bytes. Returns parsed JSON object or null.
*/
parseBlock(data) {
const bData = ensureBuffer(data);
const outPtrBuf = Buffer.alloc(8);
const outLenBuf = Buffer.alloc(8);
const rc = this.lib.symbols.salvium_parse_block(bData, bData.length, outPtrBuf, outLenBuf);
if (rc !== 0) return null;
const resultPtr = Number(outPtrBuf.readBigUInt64LE(0));
const resultLen = Number(outLenBuf.readBigUInt64LE(0));
if (!resultPtr || !resultLen) return null;
const resultBuf = new Uint8Array(toArrayBuffer(resultPtr, 0, resultLen)).slice();
this.lib.symbols.salvium_storage_free_buf(resultPtr, resultLen);
return JSON.parse(new TextDecoder().decode(resultBuf));
}
// ─── CARROT Output Scanning ──────────────────────────────────────────────
/**
@@ -953,22 +1060,8 @@ export class FfiCryptoBackend {
const bSpend = (spendSecretKey) ? ensureBuffer(spendSecretKey) : null;
const bView = ensureBuffer(viewSecretKey);
// Marshal subaddress map: each entry = 32-byte key + 4-byte major LE + 4-byte minor LE
let subBuf, nSub;
if (subaddressMap && subaddressMap.size > 0) {
nSub = subaddressMap.size;
subBuf = Buffer.alloc(nSub * 40);
let offset = 0;
for (const [hexKey, { major, minor }] of subaddressMap) {
const keyBytes = hexToBytes(hexKey);
subBuf.set(keyBytes, offset); offset += 32;
subBuf.writeUInt32LE(major, offset); offset += 4;
subBuf.writeUInt32LE(minor, offset); offset += 4;
}
} else {
nSub = 0;
subBuf = Buffer.alloc(0);
}
// Use cached subaddress map serialization
const { buf: subBuf, n: nSub } = this._marshalSubaddressMap(subaddressMap, '_cachedSub');
// Rust-allocated output pointers
const outPtrBuf = Buffer.alloc(8);
@@ -1022,22 +1115,8 @@ export class FfiCryptoBackend {
? BigInt(clearTextAmount)
: 0xFFFFFFFFFFFFFFFFn;
// Marshal subaddress map: each entry = 32-byte key + 4-byte major LE + 4-byte minor LE
let subBuf, nSub;
if (subaddressMap && subaddressMap.size > 0) {
nSub = subaddressMap.size;
subBuf = Buffer.alloc(nSub * 40);
let offset = 0;
for (const [hexKey, { major, minor }] of subaddressMap) {
const keyBytes = hexToBytes(hexKey);
subBuf.set(keyBytes, offset); offset += 32;
subBuf.writeUInt32LE(major, offset); offset += 4;
subBuf.writeUInt32LE(minor, offset); offset += 4;
}
} else {
nSub = 0;
subBuf = Buffer.alloc(0);
}
// Use cached CARROT subaddress map serialization
const { buf: subBuf, n: nSub } = this._marshalSubaddressMap(subaddressMap, '_cachedCarrotSub');
// Rust-allocated output pointers
const outPtrBuf = Buffer.alloc(8); // *mut u8
+6
View File
@@ -48,6 +48,7 @@ export class JsCryptoBackend {
// X25519 — requires Rust backend
x25519ScalarMult() { throw new Error(RUST_REQUIRED); }
edwardsToMontgomeryU() { throw new Error(RUST_REQUIRED); }
// Point ops — require Rust backend
scalarMultBase() { throw new Error(RUST_REQUIRED); }
@@ -72,6 +73,11 @@ export class JsCryptoBackend {
// RCT batch verification — returns null (JS fallback handled in validation.js)
verifyRctSignatures() { return null; }
// Full TX parsing/serialization — returns null (signals JS fallback)
parseTransaction() { return null; }
serializeTransaction() { return null; }
parseBlock() { return null; }
// CLSAG/TCLSAG/BP+ — require Rust backend
clsagSign() { throw new Error(RUST_REQUIRED); }
clsagVerify() { throw new Error(RUST_REQUIRED); }
+13
View File
@@ -75,6 +75,7 @@ export class JsiCryptoBackend {
// ─── X25519 ────────────────────────────────────────────────────────────
x25519ScalarMult(scalar, uCoord) { return this.native.x25519ScalarMult(scalar, uCoord); }
edwardsToMontgomeryU(point) { return this.native.edwardsToMontgomeryU(point); }
// ─── Hash-to-Point & Key Derivation ─────────────────────────────────────
@@ -167,6 +168,18 @@ export class JsiCryptoBackend {
return this.native.computeTxPrefixHash(data);
}
// ─── Full Transaction Parsing & Serialization ────────────────────────────
parseTransaction(data) {
return this.native.parseTransaction(data);
}
serializeTransaction(txObj) {
return this.native.serializeTransaction(txObj);
}
parseBlock(data) {
return this.native.parseBlock(data);
}
// ─── Pedersen Commitments ───────────────────────────────────────────────
commit(amount, mask) {
+17
View File
@@ -158,8 +158,25 @@ export class WasmCryptoBackend {
return this.wasm.compute_tx_prefix_hash(data);
}
// Full transaction parsing & serialization
parseTransaction(data) {
const json = this.wasm.parse_transaction_bytes(data);
if (!json || json.startsWith('{"error"')) return null;
return JSON.parse(json);
}
serializeTransaction(txObj) {
const result = this.wasm.serialize_transaction_json(JSON.stringify(txObj));
return (result && result.length > 0) ? result : null;
}
parseBlock(data) {
const json = this.wasm.parse_block_bytes(data);
if (!json || json.startsWith('{"error"')) return null;
return JSON.parse(json);
}
// X25519
x25519ScalarMult(scalar, uCoord) { return this.wasm.x25519_scalar_mult(scalar, uCoord); }
edwardsToMontgomeryU(point) { return this.wasm.edwards_to_montgomery_u(point); }
// Hash-to-point & key derivation
hashToPoint(data) { return this.wasm.hash_to_point(data); }
+1 -1
View File
@@ -42,7 +42,7 @@ export {
// Key derivation
argon2id,
// X25519
x25519ScalarMult,
x25519ScalarMult, edwardsToMontgomeryU,
// CARROT key derivation
computeCarrotSpendPubkey, computeCarrotAccountViewPubkey,
computeCarrotMainAddressViewPubkey,
+8
View File
@@ -306,6 +306,9 @@ export function computeCarrotMainAddressViewPubkey(k_vi) {
export function x25519ScalarMult(scalar, uCoord) {
return getCryptoBackend().x25519ScalarMult(scalar, uCoord);
}
export function edwardsToMontgomeryU(point) {
return getCryptoBackend().edwardsToMontgomeryU(point);
}
// Batch subaddress map generation
export function cnSubaddressMapBatch(spendPubkey, viewSecretKey, majorCount, minorCount) {
@@ -356,3 +359,8 @@ export function computeTxPrefixHash(data) {
// Scalar add (simple wrapper around scAdd)
export function scalarAdd(a, b) { return scAdd(a, b); }
// Full transaction parsing & serialization (native acceleration)
export function parseTransactionNative(...args) { return getCryptoBackend().parseTransaction(...args); }
export function serializeTransactionNative(...args) { return getCryptoBackend().serializeTransaction(...args); }
export function parseBlockNative(...args) { return getCryptoBackend().parseBlock(...args); }
+27
View File
@@ -87,6 +87,12 @@ export function derive_secret_key(derivation: Uint8Array, output_index: number,
export function double_scalar_mult_base(a: Uint8Array, p: Uint8Array, b: Uint8Array): Uint8Array;
/**
* Convert Ed25519 compressed point to X25519 u-coordinate.
* u = (1 + y) / (1 - y) mod p
*/
export function edwards_to_montgomery_u(point: Uint8Array): Uint8Array;
/**
* Generate commitment mask from shared secret
* mask = scReduce32(keccak256("commitment_mask" || sharedSecret))
@@ -125,11 +131,22 @@ export function make_input_context_coinbase(block_height: bigint): Uint8Array;
*/
export function make_input_context_rct(first_key_image: Uint8Array): Uint8Array;
/**
* Parse a complete block from raw bytes to JSON string.
*/
export function parse_block_bytes(data: Uint8Array): string;
/**
* Parse tx_extra binary into JSON string.
*/
export function parse_extra(extra_bytes: Uint8Array): string;
/**
* Parse a complete transaction from raw bytes to JSON string.
* Returns JSON with hex-encoded binary fields and decimal string amounts.
*/
export function parse_transaction_bytes(data: Uint8Array): string;
/**
* Pedersen commitment: C = mask*G + amount*H
*/
@@ -170,6 +187,12 @@ export function scalar_mult_base(s: Uint8Array): Uint8Array;
export function scalar_mult_point(s: Uint8Array, p: Uint8Array): Uint8Array;
/**
* Serialize a transaction from JSON string to raw bytes.
* Returns empty Vec on error.
*/
export function serialize_transaction_json(json: string): Uint8Array;
/**
* Serialize tx_extra from JSON to binary. Returns empty on error.
*/
@@ -242,6 +265,7 @@ export interface InitOutput {
readonly derive_public_key: (a: number, b: number, c: number, d: number, e: number) => [number, number];
readonly derive_secret_key: (a: number, b: number, c: number, d: number, e: number) => [number, number];
readonly double_scalar_mult_base: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
readonly edwards_to_montgomery_u: (a: number, b: number) => [number, number];
readonly gen_commitment_mask: (a: number, b: number) => [number, number];
readonly generate_key_derivation: (a: number, b: number, c: number, d: number) => [number, number];
readonly generate_key_image: (a: number, b: number, c: number, d: number) => [number, number];
@@ -249,7 +273,9 @@ export interface InitOutput {
readonly keccak256: (a: number, b: number) => [number, number];
readonly make_input_context_coinbase: (a: bigint) => [number, number];
readonly make_input_context_rct: (a: number, b: number) => [number, number];
readonly parse_block_bytes: (a: number, b: number) => [number, number];
readonly parse_extra: (a: number, b: number) => [number, number];
readonly parse_transaction_bytes: (a: number, b: number) => [number, number];
readonly pedersen_commit: (a: number, b: number, c: number, d: number) => [number, number];
readonly point_add_compressed: (a: number, b: number, c: number, d: number) => [number, number];
readonly point_negate: (a: number, b: number) => [number, number];
@@ -267,6 +293,7 @@ export interface InitOutput {
readonly sc_sub: (a: number, b: number, c: number, d: number) => [number, number];
readonly scalar_mult_base: (a: number, b: number) => [number, number];
readonly scalar_mult_point: (a: number, b: number, c: number, d: number) => [number, number];
readonly serialize_transaction_json: (a: number, b: number) => [number, number];
readonly serialize_tx_extra: (a: number, b: number) => [number, number];
readonly sha256: (a: number, b: number) => [number, number];
readonly tclsag_sign_wasm: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number, n: number, o: number) => [number, number];
+71
View File
@@ -345,6 +345,21 @@ export function double_scalar_mult_base(a, p, b) {
return v4;
}
/**
* Convert Ed25519 compressed point to X25519 u-coordinate.
* u = (1 + y) / (1 - y) mod p
* @param {Uint8Array} point
* @returns {Uint8Array}
*/
export function edwards_to_montgomery_u(point) {
const ptr0 = passArray8ToWasm0(point, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.edwards_to_montgomery_u(ptr0, len0);
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
/**
* Generate commitment mask from shared secret
* mask = scReduce32(keccak256("commitment_mask" || sharedSecret))
@@ -450,6 +465,26 @@ export function make_input_context_rct(first_key_image) {
return v2;
}
/**
* Parse a complete block from raw bytes to JSON string.
* @param {Uint8Array} data
* @returns {string}
*/
export function parse_block_bytes(data) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_block_bytes(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/**
* Parse tx_extra binary into JSON string.
* @param {Uint8Array} extra_bytes
@@ -470,6 +505,27 @@ export function parse_extra(extra_bytes) {
}
}
/**
* Parse a complete transaction from raw bytes to JSON string.
* Returns JSON with hex-encoded binary fields and decimal string amounts.
* @param {Uint8Array} data
* @returns {string}
*/
export function parse_transaction_bytes(data) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.parse_transaction_bytes(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/**
* Pedersen commitment: C = mask*G + amount*H
* @param {Uint8Array} amount
@@ -728,6 +784,21 @@ export function scalar_mult_point(s, p) {
return v3;
}
/**
* Serialize a transaction from JSON string to raw bytes.
* Returns empty Vec on error.
* @param {string} json
* @returns {Uint8Array}
*/
export function serialize_transaction_json(json) {
const ptr0 = passStringToWasm0(json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.serialize_transaction_json(ptr0, len0);
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
/**
* Serialize tx_extra from JSON to binary. Returns empty on error.
* @param {string} json_str
Binary file not shown.
+59 -33
View File
@@ -25,7 +25,6 @@ export * from './carrot.js';
export * from './subaddress.js';
export * from './mnemonic.js';
export * from './scanning.js';
export * from './keyimage.js';
export * from './transaction.js';
export * from './bulletproofs_plus.js';
export * from './mining.js';
@@ -268,16 +267,65 @@ import {
scanTransaction
} from './scanning.js';
import {
hashToPoint,
generateKeyImage,
deriveKeyImageGenerator,
isValidKeyImage,
keyImageToY,
keyImageFromY,
exportKeyImages,
importKeyImages
} from './keyimage.js';
// Key image functions — hashToPoint and generateKeyImage from Rust backend,
// utility functions inlined (previously from deprecated keyimage.js)
import { hashToPoint, generateKeyImage } from './crypto/index.js';
/** Alias for hashToPoint (backward compat from keyimage.js) */
const deriveKeyImageGenerator = hashToPoint;
/** Check if a key image is valid (non-zero, 32 bytes, on curve) */
function isValidKeyImage(keyImage) {
if (typeof keyImage === 'string') {
keyImage = new Uint8Array(keyImage.match(/.{1,2}/g).map(b => parseInt(b, 16)));
}
if (!keyImage || keyImage.length !== 32) return false;
if (keyImage.every(b => b === 0)) return false;
return isValidPoint(keyImage);
}
/** Extract y-coordinate and sign from key image */
function keyImageToY(keyImage) {
if (typeof keyImage === 'string') {
keyImage = new Uint8Array(keyImage.match(/.{1,2}/g).map(b => parseInt(b, 16)));
}
const y = new Uint8Array(keyImage);
const sign = (y[31] & 0x80) !== 0;
y[31] &= 0x7f;
return { y, sign };
}
/** Reconstruct key image from y-coordinate and sign */
function keyImageFromY(y, sign) {
const keyImage = new Uint8Array(y);
if (sign) keyImage[31] |= 0x80;
return keyImage;
}
/** Export key images for a list of outputs */
function exportKeyImages(outputs) {
return outputs.map(output => {
const ki = generateKeyImage(output.outputPublicKey, output.outputSecretKey);
const _bytesToHex = (b) => Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
return {
keyImage: ki ? _bytesToHex(ki) : null,
outputPublicKey: typeof output.outputPublicKey === 'string'
? output.outputPublicKey : _bytesToHex(output.outputPublicKey),
outputIndex: output.outputIndex
};
}).filter(o => o.keyImage !== null);
}
/** Import key images (for view-only wallet) */
function importKeyImages(keyImages) {
const map = new Map();
for (const { keyImage, outputPublicKey } of keyImages) {
map.set(outputPublicKey, keyImage);
}
return map;
}
export { deriveKeyImageGenerator, isValidKeyImage, keyImageToY, keyImageFromY, exportKeyImages, importKeyImages };
import {
// Scalar operations
@@ -420,25 +468,14 @@ import {
} from './rpc/index.js';
import {
bytesToScalar,
scalarToBytes,
bytesToPoint,
hashToPointMonero as bpHashToPoint,
hashToScalar as bpHashToScalar,
initGenerators,
initTranscript,
parseProof,
multiScalarMul,
verifyBulletproofPlus,
verifyBulletproofPlusBatch,
verifyRangeProof,
// Proof generation
proveRange,
proveRangeMultiple,
randomScalar,
serializeProof,
bulletproofPlusProve,
Point
} from './bulletproofs_plus.js';
import {
@@ -980,25 +1017,14 @@ const salvium = {
getTransactionHashFromParsed,
// Bulletproofs+
bytesToScalar,
scalarToBytes,
bytesToPoint,
bpHashToPoint,
bpHashToScalar,
initGenerators,
initTranscript,
parseProof,
multiScalarMul,
verifyBulletproofPlus,
verifyBulletproofPlusBatch,
verifyRangeProof,
// Proof generation
proveRange,
proveRangeMultiple,
randomScalar,
serializeProof,
bulletproofPlusProve,
Point,
// Mining
MINING_CONSTANTS,
-651
View File
@@ -1,651 +0,0 @@
/**
* Key Image Generation Module (DEPRECATED)
*
* @deprecated Use the Rust crypto backend (WASM/FFI/JSI) instead.
* hashToPoint() and generateKeyImage() are now implemented in Rust
* for correctness and performance. This module is kept for reference
* and as a fallback for direct-import consumers. It will be removed
* in a future version.
*
* Implements key image generation for Salvium transactions.
* Key images are used to detect double-spending without revealing which output was spent.
*
* Key formula: KI = x * H_p(P)
* Where:
* - x is the output secret key
* - P is the output public key
* - H_p is hash-to-point (maps 32-byte hash to curve point)
*
* Reference: crypto/crypto.cpp generate_key_image(), hash_to_ec()
*/
import { keccak256 } from './keccak.js';
import { hexToBytes, bytesToHex } from './address.js';
// Prime field: p = 2^255 - 19
const P = (1n << 255n) - 19n;
// Curve constant d = -121665/121666 mod p
const D = 37095705934669439343138083508754565189542113879843219016388785533085940283555n;
// sqrt(-1) mod p
const SQRT_M1 = 19681161376707505956807079304988542015446066515923890162744021073123829784752n;
// Montgomery curve parameter A = 486662
const A = 486662n;
// Group order L
const L = (1n << 252n) + 27742317777372353535851937790883648493n;
// Precomputed constants for Elligator 2 (computed mod p)
// -A mod p
const NEG_A = (P - A) % P;
// A^2 mod p
const A_SQUARED = (A * A) % P;
// -A^2 mod p (fe_ma2)
const NEG_A_SQUARED = (P - A_SQUARED) % P;
// A + 2
const A_PLUS_2 = A + 2n;
// 2 * A * (A + 2) mod p
const TWO_A_AP2 = (2n * A * A_PLUS_2) % P;
// A * (A + 2) mod p
const A_AP2 = (A * A_PLUS_2) % P;
// ============================================================================
// Field Arithmetic (mod p)
// ============================================================================
function feAdd(a, b) {
return (a + b) % P;
}
function feSub(a, b) {
let r = a - b;
if (r < 0n) r += P;
return r;
}
function feMul(a, b) {
return (a * b) % P;
}
function feSq(a) {
return (a * a) % P;
}
function feNeg(a) {
if (a === 0n) return 0n;
return P - a;
}
function feIsZero(a) {
return a === 0n;
}
function feIsNegative(a) {
// "Negative" means least significant bit is 1
return (a & 1n) === 1n;
}
// Modular exponentiation (square-and-multiply)
function fePow(base, exp) {
let result = 1n;
base = ((base % P) + P) % P;
while (exp > 0n) {
if (exp & 1n) result = (result * base) % P;
exp = exp >> 1n;
base = (base * base) % P;
}
return result;
}
// Modular inverse using Fermat's little theorem: a^(-1) = a^(p-2) mod p
function feInv(a) {
return fePow(a, P - 2n);
}
// Compute x^((p+3)/8) - used for square roots
// (p+3)/8 = (2^255 - 19 + 3)/8 = (2^255 - 16)/8 = 2^252 - 2
function fePowP3d8(x) {
const exp = (1n << 252n) - 2n;
return fePow(x, exp);
}
// Compute x^((p-5)/8) - used for fe_divpowm1
// (p-5)/8 = (2^255 - 19 - 5)/8 = (2^255 - 24)/8 = 2^252 - 3
function fePowPm5d8(x) {
const exp = (1n << 252n) - 3n;
return fePow(x, exp);
}
// Square root mod p
// For p ≡ 5 (mod 8), sqrt(a) = a^((p+3)/8) if a^((p-1)/4) = 1
// Otherwise sqrt(a) = sqrt(-1) * a^((p+3)/8)
function feSqrt(a) {
if (a === 0n) return 0n;
// Compute candidate = a^((p+3)/8)
const candidate = fePowP3d8(a);
// Check if candidate² = a
if (feSq(candidate) === a) return candidate;
// Try candidate * sqrt(-1)
const adjusted = feMul(candidate, SQRT_M1);
if (feSq(adjusted) === a) return adjusted;
// No square root exists
return null;
}
// fe_divpowm1: compute (u/v)^((p+3)/8) using the formula:
// (u/v)^((p+3)/8) = u * v^3 * (u * v^7)^((p-5)/8)
function feDivPowM1(u, v) {
const v2 = feSq(v);
const v3 = feMul(v2, v);
const v4 = feSq(v2);
const v7 = feMul(v4, v3);
const uv7 = feMul(u, v7);
const uv7_pow = fePowPm5d8(uv7);
return feMul(feMul(u, v3), uv7_pow);
}
// ============================================================================
// Elligator 2: Field Element to Curve Point
// Reference: crypto-ops.c ge_fromfe_frombytes_vartime
// ============================================================================
/**
* Load 32 bytes as field element (little-endian, reduced mod p)
*/
function feFromBytes(bytes) {
let result = 0n;
for (let i = 31; i >= 0; i--) {
result = (result << 8n) | BigInt(bytes[i]);
}
return result % P;
}
/**
* Convert field element to 32 bytes (little-endian)
*/
function feToBytes(fe) {
const bytes = new Uint8Array(32);
let val = ((fe % P) + P) % P;
for (let i = 0; i < 32; i++) {
bytes[i] = Number(val & 0xffn);
val = val >> 8n;
}
return bytes;
}
/**
* Elligator 2 map: hash bytes -> curve point
* Implements ge_fromfe_frombytes_vartime from crypto-ops.c
*
* @param {Uint8Array} hashBytes - 32-byte hash
* @returns {Object} Point {x, y} in affine coordinates
*/
function elligator2(hashBytes) {
// Load hash as field element u
const u = feFromBytes(hashBytes);
// v = 2 * u^2
const u2 = feSq(u);
const v = feMul(2n, u2);
// w = 2 * u^2 + 1
const w = feAdd(v, 1n);
// x = w^2 - 2 * A^2 * u^2
// = w^2 + 2 * (-A^2) * u^2
const w2 = feSq(w);
const term = feMul(feMul(2n, NEG_A_SQUARED), u2);
let x = feAdd(w2, term);
// r_X = (w / x)^((p+3)/8) via fe_divpowm1
let r_X = feDivPowM1(w, x);
// y = r_X^2 * x
let y = feMul(feSq(r_X), x);
// z starts as -A
let z = NEG_A;
// Determine branch based on whether y == w or y == -w
let sign;
// Check w - y == 0
let diff = feSub(w, y);
if (feIsZero(diff)) {
// y == w: use fffb2 = sqrt(2*A*(A+2))
// r_X = r_X * sqrt(2*A*(A+2))
const fffb2 = feSqrt(TWO_A_AP2);
if (fffb2 !== null) {
r_X = feMul(r_X, fffb2);
}
// r_X = u * r_X
r_X = feMul(r_X, u);
// z = -A * v = -2Au^2
z = feMul(z, v);
sign = false;
} else {
// Check w + y == 0
let sum = feAdd(w, y);
if (feIsZero(sum)) {
// y == -w: use fffb1 = sqrt(-2*A*(A+2))
const neg_two_a_ap2 = feNeg(TWO_A_AP2);
const fffb1 = feSqrt(neg_two_a_ap2);
if (fffb1 !== null) {
r_X = feMul(r_X, fffb1);
}
r_X = feMul(r_X, u);
z = feMul(z, v);
sign = false;
} else {
// Negative branch: multiply x by sqrt(-1)
x = feMul(x, SQRT_M1);
y = feMul(feSq(r_X), x);
diff = feSub(w, y);
if (feIsZero(diff)) {
// Use fffb4 = sqrt(sqrt(-1)*A*(A+2))
const sqrtm1_a_ap2 = feMul(SQRT_M1, A_AP2);
const fffb4 = feSqrt(sqrtm1_a_ap2);
if (fffb4 !== null) {
r_X = feMul(r_X, fffb4);
}
} else {
// Use fffb3 = sqrt(-sqrt(-1)*A*(A+2))
const neg_sqrtm1_a_ap2 = feNeg(feMul(SQRT_M1, A_AP2));
const fffb3 = feSqrt(neg_sqrtm1_a_ap2);
if (fffb3 !== null) {
r_X = feMul(r_X, fffb3);
}
}
// z remains as -A
sign = true;
}
}
// Adjust sign of r_X
if (feIsNegative(r_X) !== sign) {
r_X = feNeg(r_X);
}
// Compute projective coordinates:
// Z = z + w
// Y = z - w
// X = r_X * Z
const Z = feAdd(z, w);
const Y = feSub(z, w);
const X = feMul(r_X, Z);
// Convert to affine: x = X/Z, y = Y/Z
const Zinv = feInv(Z);
const affineX = feMul(X, Zinv);
const affineY = feMul(Y, Zinv);
return { x: affineX, y: affineY };
}
// ============================================================================
// Point Operations
// ============================================================================
/**
* Point doubling on twisted Edwards curve: - + = 1 + dx²y²
*/
function pointDouble(x, y) {
// Using unified doubling formula for twisted Edwards (a = -1)
const x2 = feSq(x);
const y2 = feSq(y);
const xy = feMul(x, y);
// x3 = 2xy / (y² - x²)
// y3 = (x² + y²) / (2 - (y² - x²)) = (x² + y²) / (2 - y² + x²)
const y2_minus_x2 = feSub(y2, x2);
const x2_plus_y2 = feAdd(x2, y2);
const two_minus = feSub(2n, y2_minus_x2);
if (feIsZero(y2_minus_x2) || feIsZero(two_minus)) {
return { x: 0n, y: 1n }; // Identity
}
const x3 = feMul(feMul(2n, xy), feInv(y2_minus_x2));
const y3 = feMul(x2_plus_y2, feInv(two_minus));
return { x: x3, y: y3 };
}
/**
* Multiply point by 8 (cofactor clearing)
* Done by doubling 3 times
*/
function pointMul8(x, y) {
let p = { x, y };
for (let i = 0; i < 3; i++) {
p = pointDouble(p.x, p.y);
}
return p;
}
/**
* Compress affine point to 32 bytes
* Ed25519 encoding: y with sign bit of x in high bit
*/
function pointCompress(x, y) {
const bytes = feToBytes(y);
if (feIsNegative(x)) {
bytes[31] |= 0x80;
}
return bytes;
}
/**
* Decompress 32 bytes to affine point
*/
function pointDecompress(bytes) {
// Extract y (clear high bit)
let y = 0n;
for (let i = 31; i >= 0; i--) {
y = (y << 8n) | BigInt(bytes[i]);
}
const xSign = (y >> 255n) & 1n;
y = y & ((1n << 255n) - 1n);
if (y >= P) return null;
// Recover x from curve equation: -x² + y² = 1 + dx²y²
// x² = (y² - 1) / (dy² + 1)
const y2 = feSq(y);
const num = feSub(y2, 1n);
const den = feAdd(feMul(D, y2), 1n);
const x2 = feMul(num, feInv(den));
let x = feSqrt(x2);
if (x === null) return null;
// Adjust sign
if (feIsNegative(x) !== (xSign === 1n)) {
x = feNeg(x);
}
return { x, y };
}
/**
* Scalar multiplication: s * P (in affine coordinates)
*/
function scalarMultAffine(s, px, py) {
let rx = 0n, ry = 1n; // Identity
while (s > 0n) {
if (s & 1n) {
// Add point
const r = pointAddAffine(rx, ry, px, py);
rx = r.x;
ry = r.y;
}
// Double base point
const d = pointDouble(px, py);
px = d.x;
py = d.y;
s = s >> 1n;
}
return { x: rx, y: ry };
}
/**
* Point addition in affine coordinates
*/
function pointAddAffine(x1, y1, x2, y2) {
// Handle identity
if (x1 === 0n && y1 === 1n) return { x: x2, y: y2 };
if (x2 === 0n && y2 === 1n) return { x: x1, y: y1 };
// Standard twisted Edwards addition formula (a = -1)
// x3 = (x1*y2 + y1*x2) / (1 + d*x1*x2*y1*y2)
// y3 = (y1*y2 + x1*x2) / (1 - d*x1*x2*y1*y2)
const x1y2 = feMul(x1, y2);
const y1x2 = feMul(y1, x2);
const y1y2 = feMul(y1, y2);
const x1x2 = feMul(x1, x2);
const dProduct = feMul(D, feMul(x1x2, y1y2));
const numX = feAdd(x1y2, y1x2);
const denX = feAdd(1n, dProduct);
const numY = feAdd(y1y2, x1x2);
const denY = feSub(1n, dProduct);
if (feIsZero(denX) || feIsZero(denY)) {
return { x: 0n, y: 1n }; // Result is identity or undefined
}
const x3 = feMul(numX, feInv(denX));
const y3 = feMul(numY, feInv(denY));
return { x: x3, y: y3 };
}
// ============================================================================
// Hash to Point (H_p)
// ============================================================================
/**
* Hash a public key to a curve point
* This is the H_p function: hash_to_ec in crypto.cpp
*
* Steps:
* 1. Hash the public key with Keccak256
* 2. Map hash to curve point using Elligator 2
* 3. Multiply by cofactor 8 to ensure point is in prime-order subgroup
*
* @param {Uint8Array|string} publicKey - 32-byte public key
* @returns {Uint8Array} 32-byte compressed curve point
*/
export function hashToPoint(publicKey) {
if (typeof publicKey === 'string') {
publicKey = hexToBytes(publicKey);
}
// Step 1: Hash the public key
const hash = keccak256(publicKey);
// Step 2: Map to curve using Elligator 2
const point = elligator2(hash);
// Step 3: Multiply by cofactor 8
const point8 = pointMul8(point.x, point.y);
// Compress to 32 bytes
return pointCompress(point8.x, point8.y);
}
// ============================================================================
// Key Image Generation
// ============================================================================
/**
* Generate key image from output public key and secret key
* KI = secretKey * H_p(publicKey)
*
* @param {Uint8Array|string} outputPublicKey - 32-byte output public key
* @param {Uint8Array|string} outputSecretKey - 32-byte output secret key
* @returns {Uint8Array|null} 32-byte key image, or null if computation fails
*/
export function generateKeyImage(outputPublicKey, outputSecretKey) {
if (typeof outputPublicKey === 'string') {
outputPublicKey = hexToBytes(outputPublicKey);
}
if (typeof outputSecretKey === 'string') {
outputSecretKey = hexToBytes(outputSecretKey);
}
// H_p(P) - hash public key to point
const hpBytes = hashToPoint(outputPublicKey);
// Decompress H_p point
const hp = pointDecompress(hpBytes);
if (!hp) return null;
// Convert secret key to scalar
let scalar = 0n;
for (let i = 31; i >= 0; i--) {
scalar = (scalar << 8n) | BigInt(outputSecretKey[i]);
}
scalar = scalar % L;
// KI = scalar * H_p(P)
const ki = scalarMultAffine(scalar, hp.x, hp.y);
// Compress result
return pointCompress(ki.x, ki.y);
}
/**
* Derive the key image generator for a public key
* This is H_p(P) without the scalar multiplication
*
* @param {Uint8Array|string} publicKey - 32-byte public key
* @returns {Uint8Array} 32-byte key image generator point
*/
export function deriveKeyImageGenerator(publicKey) {
return hashToPoint(publicKey);
}
// ============================================================================
// Key Image Validation
// ============================================================================
/**
* Check if a key image is valid (on the curve)
*
* @param {Uint8Array|string} keyImage - 32-byte key image
* @returns {boolean} True if valid
*/
export function isValidKeyImage(keyImage) {
if (typeof keyImage === 'string') {
keyImage = hexToBytes(keyImage);
}
if (keyImage.length !== 32) {
return false;
}
// Reject all zeros (not a valid key image)
let isAllZeros = true;
for (let i = 0; i < 32; i++) {
if (keyImage[i] !== 0) {
isAllZeros = false;
break;
}
}
if (isAllZeros) return false;
// Try to decompress
const point = pointDecompress(keyImage);
if (!point) return false;
// Check it's not the identity
if (point.x === 0n && point.y === 1n) return false;
// Check for degenerate points (y=0 implies x=sqrt(-1) which is technically valid but unusual)
if (point.y === 0n) return false;
// Verify on curve: -x² + y² = 1 + dx²y²
const x2 = feSq(point.x);
const y2 = feSq(point.y);
const lhs = feSub(y2, x2);
const rhs = feAdd(1n, feMul(D, feMul(x2, y2)));
return lhs === rhs;
}
/**
* Extract the y-coordinate from a key image
* @param {Uint8Array|string} keyImage - 32-byte key image
* @returns {Object} { y: Uint8Array, sign: boolean }
*/
export function keyImageToY(keyImage) {
if (typeof keyImage === 'string') {
keyImage = hexToBytes(keyImage);
}
const y = new Uint8Array(keyImage);
const sign = (y[31] & 0x80) !== 0;
y[31] &= 0x7f;
return { y, sign };
}
/**
* Reconstruct key image from y-coordinate and sign
* @param {Uint8Array} y - 32-byte y-coordinate
* @param {boolean} sign - Sign bit
* @returns {Uint8Array} 32-byte key image
*/
export function keyImageFromY(y, sign) {
const keyImage = new Uint8Array(y);
if (sign) {
keyImage[31] |= 0x80;
}
return keyImage;
}
// ============================================================================
// Export/Import for View-Only Wallets
// ============================================================================
/**
* Export key images for a list of outputs
* @param {Array} outputs - Array of { outputPublicKey, outputSecretKey, outputIndex }
* @returns {Array} Array of { keyImage, outputPublicKey, outputIndex }
*/
export function exportKeyImages(outputs) {
return outputs.map(output => {
const keyImage = generateKeyImage(output.outputPublicKey, output.outputSecretKey);
return {
keyImage: keyImage ? bytesToHex(keyImage) : null,
outputPublicKey: typeof output.outputPublicKey === 'string'
? output.outputPublicKey
: bytesToHex(output.outputPublicKey),
outputIndex: output.outputIndex
};
}).filter(o => o.keyImage !== null);
}
/**
* Import key images (for view-only wallet)
* @param {Array} keyImages - Array of { keyImage, outputPublicKey }
* @returns {Map} Map of outputPublicKey -> keyImage
*/
export function importKeyImages(keyImages) {
const map = new Map();
for (const { keyImage, outputPublicKey } of keyImages) {
map.set(outputPublicKey, keyImage);
}
return map;
}
// ============================================================================
// Exports
// ============================================================================
export default {
hashToPoint,
generateKeyImage,
deriveKeyImageGenerator,
isValidKeyImage,
keyImageToY,
keyImageFromY,
exportKeyImages,
importKeyImages
};
+6 -45
View File
@@ -20,7 +20,8 @@ import {
scalarMultBase,
scalarMultPoint,
pointAddCompressed,
scReduce32, scAdd as scalarAddBackend
scReduce32, scAdd as scalarAddBackend,
generateKeyDerivation as _generateKeyDerivation
} from './crypto/index.js';
// ============================================================================
@@ -78,51 +79,11 @@ function xorBytes(a, b) {
* @returns {Uint8Array|null} 32-byte key derivation, or null if invalid
*/
export function generateKeyDerivation(txPubKey, viewSecretKey) {
// Convert hex if needed
if (typeof txPubKey === 'string') {
txPubKey = hexToBytes(txPubKey);
}
if (typeof viewSecretKey === 'string') {
viewSecretKey = hexToBytes(viewSecretKey);
}
if (typeof txPubKey === 'string') txPubKey = hexToBytes(txPubKey);
if (typeof viewSecretKey === 'string') viewSecretKey = hexToBytes(viewSecretKey);
// Validate inputs
if (!txPubKey || txPubKey.length !== 32) {
throw new Error(`generateKeyDerivation: txPubKey must be 32 bytes, got ${txPubKey?.length ?? 'null'}`);
}
if (!viewSecretKey || viewSecretKey.length !== 32) {
throw new Error(`generateKeyDerivation: viewSecretKey must be 32 bytes, got ${viewSecretKey?.length ?? 'null'}`);
}
try {
// Convert scalar to BigInt (little-endian)
let scalar = 0n;
for (let i = 0; i < 32; i++) {
scalar |= BigInt(viewSecretKey[i]) << BigInt(i * 8);
}
// D = viewSecretKey * txPubKey (scalar mult)
const point = NoblePoint.fromBytes(txPubKey);
const result = point.multiply(scalar);
// Multiply by cofactor 8 using fast doubling (8 = 2^3)
const derivation = result.double().double().double();
return derivation.toBytes();
} catch (e) {
// Fallback to original implementation
const result = scalarMultPoint(viewSecretKey, txPubKey);
if (!result) {
throw new Error(`generateKeyDerivation: point multiplication failed - ${e.message}`);
}
const eight = new Uint8Array(32);
eight[0] = 8;
const cofactorResult = scalarMultPoint(eight, result);
if (!cofactorResult) {
throw new Error('generateKeyDerivation: cofactor multiplication failed');
}
return cofactorResult;
}
// Delegate to Rust backend: computes 8 * viewSecretKey * txPubKey in a single call
return _generateKeyDerivation(txPubKey, viewSecretKey);
}
/**
+3 -14
View File
@@ -6,7 +6,7 @@
*/
import {
keccak256, blake2b, scReduce32,
keccak256, blake2b, scReduce32, scReduce64,
scalarMultBase, scalarMultPoint, pointAddCompressed,
cnSubaddressMapBatch as _cnBatch,
carrotSubaddressMapBatch as _carrotBatch,
@@ -161,19 +161,8 @@ function deriveBytes32(domainSep, key) {
*/
function deriveScalar(domainSep, key) {
const hash64 = blake2b(domainSep, 64, key);
// Reduce 64 bytes mod L
let n = 0n;
for (let i = 63; i >= 0; i--) {
n = (n << 8n) | BigInt(hash64[i]);
}
n = n % L;
const result = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
result[i] = Number(n & 0xffn);
n = n >> 8n;
}
return result;
// Reduce 64 bytes mod L via backend (Rust or JS)
return scReduce64(hash64);
}
/**
+9 -3
View File
@@ -118,11 +118,17 @@ import {
getFeeMultiplier as _getFeeMultiplier
} from './transaction/constants.js';
// Scalar ops from crypto backend (Rust-backed)
import {
scReduce32 as _scReduce32, scInvert as _scInvert,
scAdd as _scAdd, scSub as _scSub, scMul as _scMul, scMulAdd as _scMulAdd,
randomScalar as _scRandom,
commit as _commit, genCommitmentMask as _genCommitmentMask,
} from './crypto/index.js';
// Serialization utilities (remain in serialization.js)
import {
bytesToBigInt as _bytesToBigInt, bigIntToBytes as _bigIntToBytes,
scReduce32 as _scReduce32, scInvert as _scInvert,
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,
serializeCLSAG as _serializeCLSAG, serializeTCLSAG as _serializeTCLSAG,
+87 -2
View File
@@ -29,17 +29,85 @@ import { getCryptoBackend, getCurrentBackendType } from '../crypto/provider.js';
// TRANSACTION PARSING
// =============================================================================
/**
* Convert hex string fields in a Rust-parsed result back to Uint8Array
* for JS API compatibility.
*/
function convertHexFieldsToUint8Array(obj) {
if (!obj || typeof obj !== 'object') return obj;
// Recursively process arrays
if (Array.isArray(obj)) {
return obj.map(convertHexFieldsToUint8Array);
}
const result = {};
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
result[key] = value;
} else if (Array.isArray(value)) {
result[key] = value.map(convertHexFieldsToUint8Array);
} else if (typeof value === 'object') {
result[key] = convertHexFieldsToUint8Array(value);
} else if (typeof value === 'string') {
// Convert fields that should be Uint8Array (32-byte keys, key images, etc.)
// Fields that are hex-encoded binary data (not amounts/strings)
if (['key', 'keyImage', 'return_address', 'return_pubkey',
'viewTag', 'encryptedJanusAnchor', 'p_r',
'R', 'z1', 'z2', 'c1', 'D', 'A', 'A1', 'B',
'r1', 's1', 'd1', 'spend_pubkey', 'aR', 'aR_stake',
'return_address_change_mask',
'return_view_tag', 'return_anchor_enc'].includes(key)) {
result[key] = hexToBytes(value);
} else if (key === 'amount' && value.length === 16 && /^[0-9a-f]+$/i.test(value)) {
// ecdhInfo amount is 8-byte hex, not a decimal string
result[key] = hexToBytes(value);
} else if (['amount_burnt', 'amount_slippage_limit', 'txnFee'].includes(key)) {
// These are decimal strings — convert to BigInt
result[key] = BigInt(value);
} else if (key === 'amount' && /^\d+$/.test(value)) {
// Regular amounts are decimal strings from Rust
result[key] = BigInt(value);
} else {
result[key] = value;
}
} else {
result[key] = value;
}
}
return result;
}
/**
* Parse a Salvium transaction from binary data
*
* @param {Uint8Array|string} data - Raw transaction data (binary or hex)
* @returns {Object} Parsed transaction object
*/
export function parseTransaction(data) {
export function parseTransaction(data, { useNative = false } = {}) {
if (typeof data === 'string') {
data = hexToBytes(data);
}
// Rust backend: opt-in only (the JSON→hex→Uint8Array marshalling overhead
// makes it slower than the direct JS parser for hot-path sync).
if (useNative) {
const bt = getCurrentBackendType();
if (bt === 'ffi' || bt === 'wasm' || bt === 'jsi') {
try {
const backend = getCryptoBackend();
if (backend.parseTransaction) {
const result = backend.parseTransaction(data);
if (result && !result.error) {
return convertHexFieldsToUint8Array(result);
}
}
} catch (_e) {
// Fall through to JS implementation
}
}
}
let offset = 0;
// Helper to read bytes
@@ -1168,7 +1236,24 @@ export function parsePricingRecord(data, startOffset = 0) {
* @param {Uint8Array} data - Raw binary block data
* @returns {Object} Parsed block
*/
export function parseBlock(data) {
export function parseBlock(data, { useNative = false } = {}) {
// Rust backend: opt-in only (same marshalling overhead as parseTransaction)
if (useNative) {
const bt = getCurrentBackendType();
if (bt === 'ffi' || bt === 'wasm' || bt === 'jsi') {
try {
const backend = getCryptoBackend();
if (backend.parseBlock) {
const result = backend.parseBlock(data);
if (result && !result.error) {
return convertHexFieldsToUint8Array(result);
}
}
} catch (_e) {
// Fall through to JS implementation
}
}
}
let offset = 0;
const readBytes = (count) => {
+31 -163
View File
@@ -13,7 +13,11 @@
*/
import { keccak256 } from '../keccak.js';
import { scalarMultBase, scalarMultPoint, pointAddCompressed } from '../crypto/index.js';
import {
scalarMultBase, scalarMultPoint, pointAddCompressed,
scReduce32, scReduce64, scAdd, scSub, scMul, scMulAdd, scMulSub,
scCheck, scIsZero, scInvert, randomScalar,
} from '../crypto/index.js';
import { bytesToHex, hexToBytes } from '../address.js';
// NOTE: provider.js is a safe import here despite the circular path through backend-js.js.
// ESM handles circular deps via live bindings — getCryptoBackend/getCurrentBackendType are
@@ -29,6 +33,15 @@ import {
TXIN_TYPE
} from './constants.js';
// Re-export scalar ops from crypto backend (backward compat for consumers of this module)
export {
scReduce32, scReduce64, scAdd, scSub, scMul, scMulAdd, scMulSub,
scCheck, scIsZero, scInvert,
};
// Re-export randomScalar as scRandom for backward compat
export { randomScalar as scRandom };
// =============================================================================
// SCALAR OPERATIONS MOD L
// =============================================================================
@@ -65,167 +78,6 @@ export function bigIntToBytes(n) {
return bytes;
}
/**
* Reduce a scalar mod L
* @param {Uint8Array|string} scalar - 32-byte scalar
* @returns {Uint8Array} Reduced scalar
*/
export function scReduce32(scalar) {
if (typeof scalar === 'string') {
scalar = hexToBytes(scalar);
}
const n = bytesToBigInt(scalar);
return bigIntToBytes(n % L);
}
/**
* Reduce a 64-byte scalar mod L (used after multiplication)
* @param {Uint8Array|string} scalar - 64-byte scalar
* @returns {Uint8Array} Reduced 32-byte scalar
*/
export function scReduce64(scalar) {
if (typeof scalar === 'string') {
scalar = hexToBytes(scalar);
}
const n = bytesToBigInt(scalar);
return bigIntToBytes(n % L);
}
/**
* Add two scalars mod L: result = a + b mod L
* @param {Uint8Array|string} a - First scalar
* @param {Uint8Array|string} b - Second scalar
* @returns {Uint8Array} Sum mod L
*/
export function scAdd(a, b) {
const aBig = bytesToBigInt(a);
const bBig = bytesToBigInt(b);
return bigIntToBytes((aBig + bBig) % L);
}
/**
* Subtract two scalars mod L: result = a - b mod L
* @param {Uint8Array|string} a - First scalar
* @param {Uint8Array|string} b - Second scalar
* @returns {Uint8Array} Difference mod L
*/
export function scSub(a, b) {
const aBig = bytesToBigInt(a);
const bBig = bytesToBigInt(b);
return bigIntToBytes(((aBig - bBig) % L + L) % L);
}
/**
* Multiply two scalars mod L: result = a * b mod L
* @param {Uint8Array|string} a - First scalar
* @param {Uint8Array|string} b - Second scalar
* @returns {Uint8Array} Product mod L
*/
export function scMul(a, b) {
const aBig = bytesToBigInt(a);
const bBig = bytesToBigInt(b);
return bigIntToBytes((aBig * bBig) % L);
}
/**
* Multiply-add: result = a*b + c mod L
* @param {Uint8Array|string} a - First multiplicand
* @param {Uint8Array|string} b - Second multiplicand
* @param {Uint8Array|string} c - Addend
* @returns {Uint8Array} Result mod L
*/
export function scMulAdd(a, b, c) {
const aBig = bytesToBigInt(a);
const bBig = bytesToBigInt(b);
const cBig = bytesToBigInt(c);
return bigIntToBytes((aBig * bBig + cBig) % L);
}
/**
* Multiply-subtract: result = c - a*b mod L
* @param {Uint8Array|string} a - First multiplicand
* @param {Uint8Array|string} b - Second multiplicand
* @param {Uint8Array|string} c - Minuend
* @returns {Uint8Array} Result mod L
*/
export function scMulSub(a, b, c) {
const aBig = bytesToBigInt(a);
const bBig = bytesToBigInt(b);
const cBig = bytesToBigInt(c);
return bigIntToBytes(((cBig - aBig * bBig) % L + L) % L);
}
/**
* Check if scalar is valid (less than L and non-zero for some operations)
* @param {Uint8Array|string} scalar - Scalar to check
* @returns {boolean} True if valid
*/
export function scCheck(scalar) {
const n = bytesToBigInt(scalar);
return n < L;
}
/**
* Check if scalar is zero
* @param {Uint8Array|string} scalar - Scalar to check
* @returns {boolean} True if zero
*/
export function scIsZero(scalar) {
if (typeof scalar === 'string') {
scalar = hexToBytes(scalar);
}
for (let i = 0; i < scalar.length; i++) {
if (scalar[i] !== 0) return false;
}
return true;
}
/**
* Generate a random scalar mod L
* @returns {Uint8Array} Random 32-byte scalar < L
*/
export function scRandom() {
const bytes = new Uint8Array(64);
crypto.getRandomValues(bytes);
return scReduce64(bytes);
}
/**
* Compute modular inverse: result = a^(-1) mod L
* Uses extended Euclidean algorithm / Fermat's little theorem
* @param {Uint8Array|string} a - Scalar to invert
* @returns {Uint8Array} Inverse mod L
*/
export function scInvert(a) {
const aBig = bytesToBigInt(a);
if (aBig === 0n) {
throw new Error('Cannot invert zero');
}
// Using Fermat's little theorem: a^(-1) = a^(L-2) mod L
const result = modPow(aBig, L - 2n, L);
return bigIntToBytes(result);
}
/**
* Modular exponentiation: base^exp mod m
* @param {bigint} base
* @param {bigint} exp
* @param {bigint} m
* @returns {bigint}
*/
function modPow(base, exp, m) {
let result = 1n;
base = base % m;
while (exp > 0n) {
if (exp % 2n === 1n) {
result = (result * base) % m;
}
exp = exp >> 1n;
base = (base * base) % m;
}
return result;
}
// =============================================================================
// PEDERSEN COMMITMENTS
// =============================================================================
@@ -969,7 +821,23 @@ export function serializeOutPk(commitments) {
* @param {Object} tx - Transaction object
* @returns {Uint8Array} Serialized transaction bytes
*/
export function serializeTransaction(tx) {
export function serializeTransaction(tx, { useNative = false } = {}) {
// Rust backend: opt-in only (JSON marshalling overhead)
if (useNative) {
const bt = getCurrentBackendType();
if (bt === 'ffi' || bt === 'wasm' || bt === 'jsi') {
try {
const backend = getCryptoBackend();
if (backend.serializeTransaction) {
const result = backend.serializeTransaction(tx);
if (result && result.length > 0) return result;
}
} catch (_e) {
// Fall through to JS implementation
}
}
}
// Adapt prefix structure for serializeTxPrefix
// (buildTransaction uses vin/vout, serializeTxPrefix expects inputs/outputs)
const prefixForSerialization = {
-1
View File
@@ -14,7 +14,6 @@ export * from './carrot.js';
export * from './subaddress.js';
export * from './mnemonic.js';
export * from './scanning.js';
export * from './keyimage.js';
export * from './transaction.js';
export * from './bulletproofs_plus.js';
export * from './wallet.js';
+111
View File
@@ -59,6 +59,14 @@ const STORAGE_SYMBOLS = {
salvium_storage_get_balance: { args: [u32, i64, ptr, usize, i32, ptr, ptr], returns: i32 },
salvium_storage_get_all_balances: { args: [u32, i64, i32, ptr, ptr], returns: i32 },
// Stake operations
salvium_storage_put_stake: { args: [u32, ptr, usize], returns: i32 },
salvium_storage_get_stake: { args: [u32, ptr, usize, ptr, ptr], returns: i32 },
salvium_storage_get_stakes: { args: [u32, ptr, usize, ptr, ptr], returns: i32 },
salvium_storage_get_stake_by_output_key: { args: [u32, ptr, usize, ptr, ptr], returns: i32 },
salvium_storage_mark_stake_returned: { args: [u32, ptr, usize], returns: i32 },
salvium_storage_delete_stakes_above: { args: [u32, i64], returns: i32 },
salvium_storage_free_buf: { args: [ptr, usize], returns: FFIType.void },
};
@@ -275,6 +283,17 @@ export class FfiStorage extends WalletStorage {
if (rc !== 0) throw new Error('putBlockHash failed');
}
async putBlockHashBatch(entries) {
const lib = getLib();
for (const { height, hash } of entries) {
const hashBuf = Buffer.from(hash, 'utf-8');
const rc = lib.symbols.salvium_storage_put_block_hash(
this._handle, height, hashBuf, hashBuf.length
);
if (rc !== 0) throw new Error(`putBlockHash failed at height ${height}`);
}
}
async getBlockHash(height) {
const outPtrBuf = Buffer.alloc(8);
const outLenBuf = Buffer.alloc(8);
@@ -401,6 +420,98 @@ export class FfiStorage extends WalletStorage {
}
return result;
}
// ── Stake Operations ─────────────────────────────────────────────────
async putStake(record) {
const data = record.toJSON ? record.toJSON() : { ...record };
// Convert BigInt values to strings for JSON serialization
if (typeof data.amountStaked === 'bigint') data.amountStaked = data.amountStaked.toString();
if (typeof data.fee === 'bigint') data.fee = data.fee.toString();
if (typeof data.returnAmount === 'bigint') data.returnAmount = data.returnAmount.toString();
const json = JSON.stringify(data);
const buf = Buffer.from(json, 'utf-8');
const rc = getLib().symbols.salvium_storage_put_stake(this._handle, buf, buf.length);
if (rc !== 0) throw new Error('putStake failed');
return record;
}
async getStake(stakeTxHash) {
if (!stakeTxHash) return null;
const kBuf = Buffer.from(stakeTxHash, 'utf-8');
const outPtrBuf = Buffer.alloc(8);
const outLenBuf = Buffer.alloc(8);
const rc = getLib().symbols.salvium_storage_get_stake(
this._handle, kBuf, kBuf.length, outPtrBuf, outLenBuf
);
if (rc !== 0) return null;
const jsonStr = readAndFree(outPtrBuf, outLenBuf);
if (!jsonStr) return null;
const data = JSON.parse(jsonStr);
// Convert amount strings to BigInt for consistency with MemoryStorage
data.amountStaked = BigInt(data.amountStaked || '0');
data.fee = BigInt(data.fee || '0');
data.returnAmount = BigInt(data.returnAmount || '0');
return data;
}
async getStakes(query = {}) {
const queryJson = JSON.stringify(query);
const qBuf = Buffer.from(queryJson, 'utf-8');
const outPtrBuf = Buffer.alloc(8);
const outLenBuf = Buffer.alloc(8);
const rc = getLib().symbols.salvium_storage_get_stakes(
this._handle, qBuf, qBuf.length, outPtrBuf, outLenBuf
);
if (rc !== 0) return [];
const jsonStr = readAndFree(outPtrBuf, outLenBuf);
if (!jsonStr) return [];
const rows = JSON.parse(jsonStr);
return rows.map(r => ({
...r,
amountStaked: BigInt(r.amountStaked || '0'),
fee: BigInt(r.fee || '0'),
returnAmount: BigInt(r.returnAmount || '0'),
}));
}
async getStakeByOutputKey(changeOutputKey) {
if (!changeOutputKey) return null;
const kBuf = Buffer.from(changeOutputKey, 'utf-8');
const outPtrBuf = Buffer.alloc(8);
const outLenBuf = Buffer.alloc(8);
const rc = getLib().symbols.salvium_storage_get_stake_by_output_key(
this._handle, kBuf, kBuf.length, outPtrBuf, outLenBuf
);
if (rc !== 0) return null;
const jsonStr = readAndFree(outPtrBuf, outLenBuf);
if (!jsonStr) return null;
const data = JSON.parse(jsonStr);
data.amountStaked = BigInt(data.amountStaked || '0');
data.fee = BigInt(data.fee || '0');
data.returnAmount = BigInt(data.returnAmount || '0');
return data;
}
async markStakeReturned(stakeTxHash, returnInfo) {
const data = {
stakeTxHash,
returnTxHash: returnInfo.returnTxHash,
returnHeight: returnInfo.returnHeight,
returnTimestamp: returnInfo.returnTimestamp,
returnAmount: (returnInfo.returnAmount !== undefined
? returnInfo.returnAmount.toString() : '0'),
};
const json = JSON.stringify(data);
const buf = Buffer.from(json, 'utf-8');
const rc = getLib().symbols.salvium_storage_mark_stake_returned(this._handle, buf, buf.length);
if (rc !== 0) throw new Error('markStakeReturned failed');
}
async deleteStakesAbove(height) {
const rc = getLib().symbols.salvium_storage_delete_stakes_above(this._handle, height);
if (rc !== 0) throw new Error('deleteStakesAbove failed');
}
}
export default FfiStorage;
+18
View File
@@ -75,6 +75,9 @@ export class WalletStorage {
// Block hash tracking (for reorg detection)
async putBlockHash(height, hash) { throw new Error('Not implemented'); }
async putBlockHashBatch(entries) {
for (const { height, hash } of entries) await this.putBlockHash(height, hash);
}
async getBlockHash(height) { throw new Error('Not implemented'); }
async deleteBlockHashesAbove(height) { throw new Error('Not implemented'); }
@@ -659,6 +662,21 @@ export class MemoryStorage extends WalletStorage {
}
}
async putBlockHashBatch(entries) {
let maxHeight = 0;
for (const { height, hash } of entries) {
this._blockHashes.set(height, hash);
if (height > maxHeight) maxHeight = height;
}
// Single prune pass at end
const cutoff = maxHeight - this._blockHashRetention;
if (cutoff > 0 && this._blockHashes.size > this._blockHashRetention * 1.5) {
for (const h of this._blockHashes.keys()) {
if (h < cutoff) this._blockHashes.delete(h);
}
}
}
async getBlockHash(height) {
return this._blockHashes.get(height) || null;
}
+108 -45
View File
@@ -42,7 +42,13 @@ export const MIN_BATCH_SIZE = 2;
/**
* Maximum batch size (ceiling) - prevent memory/timeout issues
*/
export const MAX_BATCH_SIZE = 1000;
export const MAX_BATCH_SIZE = 5000;
/**
* Target batch processing time in milliseconds.
* Batch size is adjusted to converge toward this target.
*/
export const TARGET_BATCH_TIME = 5000;
/**
* Maximum concurrent RPC calls for parallel block fetching
@@ -426,6 +432,7 @@ export class WalletSync {
);
// ── Phase 1: Fetch block headers in bulk (1 RPC) ──────────────────────
const t0 = Date.now();
const headersResponse = await this.daemon.getBlockHeadersRange(
this.currentHeight,
endHeight - 1
@@ -437,6 +444,7 @@ export class WalletSync {
const headers = headersResponse.result.headers || [];
if (headers.length === 0) return;
const tHeaders = Date.now();
// ── Phase 2: Try binary bulk fetch (1 RPC for all blocks) ─────────────
const heights = headers.map(h => h.height);
@@ -446,8 +454,16 @@ export class WalletSync {
try {
const binResp = await this.daemon.getBlocksByHeight(heights);
if (binResp.success && binResp.result.blocks?.length === headers.length) {
const tFetch = Date.now();
await this._processBinaryBatch(headers, binResp.result.blocks);
usedBinaryPath = true;
const tProcess = Date.now();
this._lastBatchTiming = {
path: 'binary',
headerMs: tHeaders - t0,
fetchMs: tFetch - tHeaders,
processMs: tProcess - tFetch
};
}
} catch (e) {
// Binary endpoint failed — fall through to JSON path
@@ -456,7 +472,14 @@ export class WalletSync {
// ── Phase 2b: Fallback — parallel JSON fetch ──────────────────────────
if (!usedBinaryPath) {
const tFallback = Date.now();
await this._syncBatchJsonFallback(headers);
this._lastBatchTiming = {
path: 'json-fallback',
headerMs: tHeaders - t0,
fetchMs: 0,
processMs: Date.now() - tFallback
};
}
// Save sync height
@@ -475,6 +498,12 @@ export class WalletSync {
* @private
*/
async _processBinaryBatch(headers, binaryBlocks) {
// Collect block hashes to flush in a single batch at the end
const pendingBlockHashes = [];
// Fine-grained timing accumulators
let tParse = 0, tHash = 0, tMiner = 0, tProto = 0, tRegular = 0, nRegularTxs = 0;
for (let idx = 0; idx < headers.length; idx++) {
if (this._stopRequested) break;
const header = headers[idx];
@@ -482,42 +511,49 @@ export class WalletSync {
try {
// Parse the block blob → { minerTx, protocolTx, txHashes, header: {...} }
let t1 = Date.now();
const blockBlob = binBlock.block instanceof Uint8Array
? binBlock.block
: new Uint8Array(binBlock.block);
const parsed = parseBlock(blockBlob);
tParse += Date.now() - t1;
// Use tx hashes from block header (reliable) instead of computing from blob
// (Salvium v3 tx hashing differs from Monero v2 — blob-based hash is wrong)
t1 = Date.now();
const minerTxHash = header.miner_tx_hash
|| this._computeTxHashFromBlob(blockBlob, parsed.minerTx);
const protocolTxHash = header.protocol_tx_hash
|| this._computeTxHashFromBlob(blockBlob, parsed.protocolTx);
tHash += Date.now() - t1;
// Process miner_tx (coinbase)
if (parsed.minerTx && minerTxHash) {
t1 = Date.now();
await this._processParsedTransaction(
parsed.minerTx, minerTxHash, header,
{ isMinerTx: true, isProtocolTx: false }
);
tMiner += Date.now() - t1;
}
// Process protocol_tx
if (parsed.protocolTx && protocolTxHash) {
t1 = Date.now();
await this._processParsedTransaction(
parsed.protocolTx, protocolTxHash, header,
{ isMinerTx: false, isProtocolTx: true }
);
tProto += Date.now() - t1;
}
// Process regular transactions (blobs included in binary response)
const txBlobs = binBlock.txs || [];
for (let ti = 0; ti < txBlobs.length; ti++) {
t1 = Date.now();
const txBlobBytes = txBlobs[ti] instanceof Uint8Array
? txBlobs[ti]
: new Uint8Array(txBlobs[ti]);
const tx = parseTransaction(txBlobBytes);
// tx_hashes from the block tell us the hash for each regular tx
const txHashBytes = parsed.txHashes[ti];
const txHash = txHashBytes ? bytesToHex(txHashBytes) : null;
if (tx && txHash) {
@@ -526,6 +562,8 @@ export class WalletSync {
{ isMinerTx: false, isProtocolTx: false }
);
}
tRegular += Date.now() - t1;
nRegularTxs++;
}
// Emit new block event
@@ -543,15 +581,32 @@ export class WalletSync {
console.error(`Binary parse failed at height ${header.height}: ${e.message}`);
}
await this.storage.putBlockHash(header.height, header.hash);
pendingBlockHashes.push({ height: header.height, hash: header.hash });
this.currentHeight = header.height + 1;
this._emit('syncProgress', this.getProgress());
// Yield to event loop every 5 blocks to prevent UI freeze on mobile
if (idx % 5 === 4) {
// Emit progress + yield periodically (not per-block — reduces overhead on large batches)
if (idx % 50 === 49 || idx === headers.length - 1) {
this._emit('syncProgress', this.getProgress());
await new Promise(r => setTimeout(r, 0));
}
}
// Flush block hashes in batch (avoids per-block FFI/disk overhead)
const tFlush0 = Date.now();
if (this.storage.putBlockHashBatch) {
await this.storage.putBlockHashBatch(pendingBlockHashes);
} else {
for (const { height, hash } of pendingBlockHashes) {
await this.storage.putBlockHash(height, hash);
}
}
const tFlush = Date.now() - tFlush0;
// Store fine-grained breakdown for batchComplete event
this._lastProcessBreakdown = {
parse: tParse, hash: tHash, miner: tMiner, proto: tProto,
regular: tRegular, nRegularTxs, flush: tFlush
};
}
/**
@@ -669,6 +724,7 @@ export class WalletSync {
}
// Process blocks sequentially with pre-fetched data
const pendingBlockHashes = [];
for (let idx = 0; idx < headers.length; idx++) {
if (this._stopRequested) break;
const header = headers[idx];
@@ -679,25 +735,34 @@ export class WalletSync {
}
await this._processBlockPrefetched(header, parsed.block, parsed.blockJson, txDataMap);
await this.storage.putBlockHash(header.height, header.hash);
pendingBlockHashes.push({ height: header.height, hash: header.hash });
this.currentHeight = header.height + 1;
this._emit('syncProgress', this.getProgress());
// Yield to event loop every 5 blocks to prevent UI freeze on mobile
if (idx % 5 === 4) {
// Emit progress + yield periodically
if (idx % 50 === 49 || idx === headers.length - 1) {
this._emit('syncProgress', this.getProgress());
await new Promise(r => setTimeout(r, 0));
}
}
// Flush block hashes in batch
if (this.storage.putBlockHashBatch) {
await this.storage.putBlockHashBatch(pendingBlockHashes);
} else {
for (const { height, hash } of pendingBlockHashes) {
await this.storage.putBlockHash(height, hash);
}
}
}
/**
* Adjust batch size based on per-block throughput trend.
* Adjust batch size based on target batch time.
*
* Tracks ms/block across batches:
* - If per-block time dropped (faster): scale up aggressively
* - If per-block time more than doubled (slower): scale back ~30%
* - If per-block time rose modestly: scale back gently
* - If roughly stable: nudge up slightly
* Computes the ideal batch size to hit TARGET_BATCH_TIME, then applies
* damping to prevent wild oscillation:
* - Max 4x increase per step (for fast ramp-up on empty block ranges)
* - Max 0.25x decrease per step (graceful backoff on heavy blocks)
* - EMA smoothing on ms/block to avoid reacting to single-batch noise
*
* @private
*/
@@ -705,35 +770,23 @@ export class WalletSync {
const elapsed = Date.now() - batchStartTime;
const msPerBlock = blocksProcessed > 0 ? elapsed / blocksProcessed : elapsed;
const prev = this._lastMsPerBlock;
let newSize = this.batchSize;
if (prev > 0) {
const ratio = msPerBlock / prev;
if (ratio > 2.0) {
// Processing time more than doubled — scale back hard
newSize = Math.round(this.batchSize * 0.5);
} else if (ratio > 1.3) {
// Slowed down moderately — scale back gently
newSize = Math.round(this.batchSize * 0.75);
} else if (ratio < 0.5) {
// Processing time dropped by more than half — scale up aggressively
newSize = Math.round(this.batchSize * 2.0);
} else if (ratio < 0.8) {
// Getting faster — scale up
newSize = Math.round(this.batchSize * 1.5);
} else {
// Stable — nudge up 10%
newSize = Math.round(this.batchSize * 1.1);
}
// EMA-smoothed ms/block (α = 0.3 for responsiveness)
if (this._lastMsPerBlock > 0) {
this._smoothMsPerBlock = 0.3 * msPerBlock + 0.7 * (this._smoothMsPerBlock || this._lastMsPerBlock);
} else {
// First batch — if it was fast, double; otherwise keep
if (msPerBlock < 50) {
newSize = Math.round(this.batchSize * 2.0);
}
this._smoothMsPerBlock = msPerBlock;
}
// Target-time-based: compute ideal batch size to fill TARGET_BATCH_TIME
const idealSize = this._smoothMsPerBlock > 0
? Math.round(TARGET_BATCH_TIME / this._smoothMsPerBlock)
: this.batchSize * 2;
// Damping: limit per-step change to 4x up / 0.25x down
let newSize = idealSize;
newSize = Math.min(newSize, this.batchSize * 4);
newSize = Math.max(newSize, Math.round(this.batchSize * 0.25));
this.batchSize = Math.max(MIN_BATCH_SIZE, Math.min(MAX_BATCH_SIZE, newSize));
this._lastMsPerBlock = msPerBlock;
this._lastBatchBlocks = blocksProcessed;
@@ -743,7 +796,9 @@ export class WalletSync {
batchSize: this.batchSize,
blocksProcessed,
msPerBlock: Math.round(msPerBlock),
blocksPerSec: blocksProcessed / (elapsed / 1000)
blocksPerSec: blocksProcessed / (elapsed / 1000),
timing: this._lastBatchTiming || null,
breakdown: this._lastProcessBreakdown || null
});
}
@@ -1543,8 +1598,16 @@ export class WalletSync {
commitment = bytesToHex(tx.rct.outPk[i]);
}
// If key image generation failed, create a deterministic synthetic key image
// from txHash + outputIndex. This prevents SQLite from creating duplicate rows
// (NULL PRIMARY KEYs are treated as distinct in SQLite) and ensures the output
// can be uniquely identified. Synthetic key images start with "00" prefix
// (not a valid Ed25519 point) to distinguish them from real key images.
const keyImage = scanResult.keyImage
|| ('00' + txHash + String(i).padStart(2, '0'));
const walletOutput = new WalletOutput({
keyImage: scanResult.keyImage,
keyImage,
publicKey: bytesToHex(outputPubKey),
txHash,
outputIndex: i,
+4 -4
View File
@@ -963,7 +963,7 @@ export class Wallet {
}
}
}
return { balance, unlockedBalance, lockedBalance: balance - unlockedBalance, stakedBalance };
return { balance: balance + stakedBalance, unlockedBalance, lockedBalance: balance + stakedBalance - unlockedBalance, stakedBalance };
}
// Legacy path (populated by sync())
@@ -998,9 +998,9 @@ export class Wallet {
}
return {
balance,
balance: balance + stakedBalance,
unlockedBalance,
lockedBalance: balance - unlockedBalance,
lockedBalance: balance + stakedBalance - unlockedBalance,
stakedBalance
};
}
@@ -2246,7 +2246,7 @@ export class Wallet {
stakedBalance += typeof s.amountStaked === 'bigint' ? s.amountStaked : BigInt(s.amountStaked || 0);
}
}
return { balance, unlockedBalance, lockedBalance: balance - unlockedBalance, stakedBalance };
return { balance: balance + stakedBalance, unlockedBalance, lockedBalance: balance + stakedBalance - unlockedBalance, stakedBalance };
}
/**
+1 -1
View File
@@ -9,7 +9,7 @@ import { clsagSign, clsagVerify, scSub } from '../src/transaction.js';
import { scalarMultBase, scalarMultPoint } from '../src/crypto/index.js';
import { keccak256 } from '../src/keccak.js';
import { bytesToHex, hexToBytes } from '../src/address.js';
import { generateKeyImage } from '../src/keyimage.js';
import { generateKeyImage } from '../src/crypto/index.js';
// Generate a random 32-byte scalar (reduced mod L)
function randomScalar() {
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env bun
/**
* Balance Diagnostic Script
*
* Runs a full sync then checks all "unspent" output key images against
* the blockchain via is_key_image_spent to find:
* 1. False positives (outputs we detected that aren't really ours)
* 2. Missed spends (our outputs that are spent on-chain but not marked)
*
* Usage:
* WALLET_SEED="..." bun test/diagnose-balance.js
* WALLET_SEED="..." STORAGE_BACKEND=ffi CRYPTO_BACKEND=ffi bun test/diagnose-balance.js
*/
import { createDaemonRPC } from '../src/rpc/index.js';
import { mnemonicToSeed } from '../src/mnemonic.js';
import { deriveKeys, deriveCarrotKeys } from '../src/carrot.js';
import { hexToBytes, bytesToHex } from '../src/address.js';
import { MemoryStorage } from '../src/wallet-store.js';
import { WalletSync } from '../src/wallet-sync.js';
import { generateCNSubaddressMap, generateCarrotSubaddressMap, SUBADDRESS_LOOKAHEAD_MAJOR, SUBADDRESS_LOOKAHEAD_MINOR } from '../src/subaddress.js';
import { initCrypto, setCryptoBackend, getCurrentBackendType } from '../src/crypto/index.js';
let FfiStorage = null;
if (process.env.STORAGE_BACKEND === 'ffi') {
FfiStorage = (await import('../src/wallet-store-ffi.js')).FfiStorage;
}
const DAEMON_URL = process.env.DAEMON_URL || 'http://seed01.salvium.io:19081';
async function run() {
// Init crypto
const requestedBackend = process.env.CRYPTO_BACKEND || 'wasm';
if (requestedBackend === 'ffi') {
await setCryptoBackend('ffi');
} else {
await initCrypto();
}
console.log(`Crypto backend: ${getCurrentBackendType()}`);
// Get keys
if (!process.env.WALLET_SEED) {
console.error('Set WALLET_SEED env var');
process.exit(1);
}
const { seed, valid, error } = mnemonicToSeed(process.env.WALLET_SEED.trim(), { language: 'auto' });
if (!valid) { console.error('Bad mnemonic:', error); process.exit(1); }
const keys = deriveKeys(seed);
const carrotKeys = deriveCarrotKeys(keys.spendSecretKey);
// Setup daemon
const daemon = createDaemonRPC({ url: DAEMON_URL, timeout: 30000 });
const info = await daemon.getInfo();
if (!info.success) { console.error('Daemon unreachable'); process.exit(1); }
console.log(`Daemon height: ${info.result.height}`);
// Setup storage
const storageBackend = process.env.STORAGE_BACKEND || 'memory';
let storage;
if (storageBackend === 'ffi' && FfiStorage) {
const dbPath = process.env.STORAGE_PATH || '/tmp/salvium-diag.db';
const keyBytes = new Uint8Array(32);
storage = new FfiStorage({ path: dbPath, key: keyBytes });
console.log(`Storage: ffi → ${dbPath}`);
} else {
storage = new MemoryStorage();
console.log('Storage: memory');
}
await storage.open();
await storage.clear();
await storage.setSyncHeight(0);
// Subaddress maps
console.log('Generating subaddress maps...');
const cnSubaddresses = generateCNSubaddressMap(
keys.spendPublicKey, keys.viewSecretKey,
SUBADDRESS_LOOKAHEAD_MAJOR, SUBADDRESS_LOOKAHEAD_MINOR
);
const carrotSubaddresses = generateCarrotSubaddressMap(
hexToBytes(carrotKeys.accountSpendPubkey),
hexToBytes(carrotKeys.accountViewPubkey),
hexToBytes(carrotKeys.generateAddressSecret),
SUBADDRESS_LOOKAHEAD_MAJOR, SUBADDRESS_LOOKAHEAD_MINOR
);
console.log(` CN: ${cnSubaddresses.size}, CARROT: ${carrotSubaddresses.size}`);
// Setup sync
const sync = new WalletSync({
storage, daemon,
keys: {
viewSecretKey: keys.viewSecretKey,
spendPublicKey: keys.spendPublicKey,
spendSecretKey: keys.spendSecretKey
},
carrotKeys: {
viewIncomingKey: hexToBytes(carrotKeys.viewIncomingKey),
accountSpendPubkey: hexToBytes(carrotKeys.accountSpendPubkey),
generateImageKey: hexToBytes(carrotKeys.generateImageKey),
generateAddressSecret: hexToBytes(carrotKeys.generateAddressSecret),
viewBalanceSecret: hexToBytes(carrotKeys.viewBalanceSecret)
},
subaddresses: cnSubaddresses,
carrotSubaddresses,
batchSize: 100
});
// Progress
let lastH = 0;
sync.on('syncProgress', (d) => {
if (d.currentHeight - lastH >= 5000) {
console.log(` Height ${d.currentHeight} (${d.percentComplete.toFixed(1)}%)`);
lastH = d.currentHeight;
}
});
// Run sync
console.log('\nSyncing...');
const t0 = Date.now();
await sync.start(0);
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
console.log(`Sync complete in ${elapsed}s`);
// Get all outputs
const outputs = await storage.getOutputs();
const syncHeight = await storage.getSyncHeight();
// Separate by spent status
const unspent = outputs.filter(o => !o.isSpent);
const spent = outputs.filter(o => o.isSpent);
console.log(`\n${'='.repeat(60)}`);
console.log('OUTPUT SUMMARY');
console.log(`${'='.repeat(60)}`);
console.log(`Total outputs: ${outputs.length}`);
console.log(`Spent: ${spent.length}`);
console.log(`Unspent: ${unspent.length}`);
// Group by asset type
const byAsset = {};
for (const o of unspent) {
const at = o.assetType || 'SAL';
if (!byAsset[at]) byAsset[at] = { count: 0, total: 0n };
byAsset[at].count++;
byAsset[at].total += BigInt(o.amount);
}
console.log('\nUnspent by asset type:');
for (const [at, data] of Object.entries(byAsset)) {
console.log(` ${at}: ${data.count} outputs, ${(Number(data.total) / 1e8).toFixed(8)} total`);
}
// Group by txType
const byTxType = {};
for (const o of unspent) {
const tt = o.txType || 'unknown';
if (!byTxType[tt]) byTxType[tt] = { count: 0, total: 0n };
byTxType[tt].count++;
byTxType[tt].total += BigInt(o.amount);
}
console.log('\nUnspent by tx type:');
for (const [tt, data] of Object.entries(byTxType)) {
console.log(` ${tt}: ${data.count} outputs, ${(Number(data.total) / 1e8).toFixed(8)} total`);
}
// Group by isCarrot
const cnUnspent = unspent.filter(o => !o.isCarrot);
const carrotUnspent = unspent.filter(o => o.isCarrot);
const cnTotal = cnUnspent.reduce((s, o) => s + BigInt(o.amount), 0n);
const carrotTotal = carrotUnspent.reduce((s, o) => s + BigInt(o.amount), 0n);
console.log(`\nCN unspent: ${cnUnspent.length} outputs, ${(Number(cnTotal) / 1e8).toFixed(8)}`);
console.log(`CARROT unspent: ${carrotUnspent.length} outputs, ${(Number(carrotTotal) / 1e8).toFixed(8)}`);
// Check key images with null/empty
const nullKi = unspent.filter(o => !o.keyImage);
const nullKiTotal = nullKi.reduce((s, o) => s + BigInt(o.amount), 0n);
console.log(`\nNull key image unspent: ${nullKi.length} outputs, ${(Number(nullKiTotal) / 1e8).toFixed(8)}`);
if (nullKi.length > 0) {
console.log(' Sample null-ki outputs:');
for (const o of nullKi.slice(0, 10)) {
console.log(` h=${o.blockHeight} amt=${(Number(o.amount)/1e8).toFixed(4)} carrot=${o.isCarrot} type=${o.txType} sub=${o.subaddressIndex?.major},${o.subaddressIndex?.minor}`);
}
}
// ============================================
// KEY IMAGE SPENT CHECK
// ============================================
console.log(`\n${'='.repeat(60)}`);
console.log('KEY IMAGE VERIFICATION');
console.log(`${'='.repeat(60)}`);
// Filter unspent outputs with valid key images
const checkable = unspent.filter(o => o.keyImage && o.keyImage.length === 64);
console.log(`\nCheckable unspent outputs (valid key images): ${checkable.length}`);
// Check in batches of 100
let onchainSpent = 0;
let onchainUnspent = 0;
let onchainPool = 0;
let onchainError = 0;
const falseUnspent = []; // Outputs we think are unspent but blockchain says are spent
const BATCH = 100;
for (let i = 0; i < checkable.length; i += BATCH) {
const batch = checkable.slice(i, i + BATCH);
const keyImages = batch.map(o => o.keyImage);
try {
const resp = await daemon.isKeyImageSpent(keyImages);
if (resp.success && resp.result?.spent_status) {
for (let j = 0; j < resp.result.spent_status.length; j++) {
const status = resp.result.spent_status[j];
if (status === 0) {
onchainUnspent++;
} else if (status === 1) {
onchainSpent++;
falseUnspent.push(batch[j]);
} else if (status === 2) {
onchainPool++;
}
}
} else {
onchainError += batch.length;
console.error(` is_key_image_spent batch ${i} failed:`, resp.error?.message);
}
} catch (e) {
onchainError += batch.length;
console.error(` is_key_image_spent batch ${i} error:`, e.message);
}
if (i > 0 && i % 500 === 0) console.log(` Checked ${i}/${checkable.length}...`);
}
console.log(`\nKey image verification results:`);
console.log(` On-chain unspent: ${onchainUnspent}`);
console.log(` On-chain spent (MISSED): ${onchainSpent}`);
console.log(` In mempool: ${onchainPool}`);
console.log(` Errors: ${onchainError}`);
if (falseUnspent.length > 0) {
const missedTotal = falseUnspent.reduce((s, o) => s + BigInt(o.amount), 0n);
console.log(`\n MISSED SPENDS: ${falseUnspent.length} outputs worth ${(Number(missedTotal) / 1e8).toFixed(8)}`);
console.log(` Sample missed spends:`);
for (const o of falseUnspent.slice(0, 20)) {
console.log(` h=${o.blockHeight} amt=${(Number(o.amount)/1e8).toFixed(4)} ki=${o.keyImage.slice(0,16)}... carrot=${o.isCarrot} type=${o.txType}`);
}
}
// Compute corrected balance
const missedTotal = falseUnspent.reduce((s, o) => s + BigInt(o.amount), 0n);
const nullKiBalance = nullKi.reduce((s, o) => s + BigInt(o.amount), 0n);
const totalUnspentBalance = unspent.reduce((s, o) => s + BigInt(o.amount), 0n);
const correctedBalance = totalUnspentBalance - missedTotal;
console.log(`\n${'='.repeat(60)}`);
console.log('BALANCE ANALYSIS');
console.log(`${'='.repeat(60)}`);
console.log(`Raw unspent balance: ${(Number(totalUnspentBalance) / 1e8).toFixed(8)}`);
console.log(`Missed spends: -${(Number(missedTotal) / 1e8).toFixed(8)}`);
console.log(`Corrected balance: ${(Number(correctedBalance) / 1e8).toFixed(8)}`);
console.log(`Expected (C++ wallet): 3367.84799534`);
console.log(`Null-ki balance: ${(Number(nullKiBalance) / 1e8).toFixed(8)} (would be false positives if ki is wrong)`);
// Check if corrected balance matches
const expectedAtomic = 336784799534n;
const diff = correctedBalance - expectedAtomic;
console.log(`\nDifference from expected: ${(Number(diff) / 1e8).toFixed(8)}`);
if (Math.abs(Number(diff)) < 1e8) {
console.log('✓ Corrected balance matches C++ wallet (within 1 SAL)');
console.log('\nROOT CAUSE: Key images are generated correctly but spent detection is missing these outputs.');
console.log('This means the _checkSpentOutputs method is not seeing the spending transactions.');
} else if (correctedBalance > expectedAtomic) {
console.log('✗ Corrected balance STILL higher than expected.');
console.log('This suggests FALSE POSITIVE outputs (we detected outputs that are not ours).');
console.log('These false-positive outputs would have WRONG key images that the blockchain');
console.log('does not recognize, so is_key_image_spent returns "unspent" for them.');
} else {
console.log('✗ Corrected balance LOWER than expected. Possible missing outputs.');
}
// Additional: check the unspent outputs that daemon also says are unspent
// Are their amounts reasonable?
const trueUnspent = checkable.filter(o => !falseUnspent.includes(o));
const trueUnspentTotal = trueUnspent.reduce((s, o) => s + BigInt(o.amount), 0n);
console.log(`\nTrue unspent outputs: ${trueUnspent.length}, total: ${(Number(trueUnspentTotal) / 1e8).toFixed(8)}`);
// Close storage
await storage.close();
}
run().catch(e => { console.error(e); process.exit(1); });
+1 -1
View File
@@ -27,8 +27,8 @@ import {
import { verifyRctSignatures, validateTransactionFull } from '../src/validation.js';
import {
scalarMultBase, scalarMultPoint, pointAddCompressed, getGeneratorT,
generateKeyImage,
} from '../src/crypto/index.js';
import { generateKeyImage } from '../src/keyimage.js';
import { bytesToHex, hexToBytes } from '../src/address.js';
await initCrypto();
+1 -1
View File
@@ -5,7 +5,7 @@ import { createDaemonRPC } from '../src/rpc/index.js';
import { MemoryStorage, WalletOutput, WalletTransaction } from '../src/wallet-store.js';
import { parseTransaction, extractTxPubKey, extractPaymentId } from '../src/transaction.js';
import { generateKeyDerivation, derivePublicKey, deriveViewTag, computeSharedSecret, ecdhDecodeFull } from '../src/scanning.js';
import { generateKeyImage } from '../src/keyimage.js';
import { generateKeyImage } from '../src/crypto/index.js';
import { mnemonicToSeed } from '../src/mnemonic.js';
import { deriveKeys } from '../src/carrot.js';
import { hexToBytes, bytesToHex } from '../src/address.js';
+116 -65
View File
@@ -292,6 +292,12 @@ async function runIntegrationTest() {
console.log('Storage backend: memory');
}
await storage.open();
// Clear database for fresh syncs to avoid stale data from previous runs
// (e.g. NULL key image duplicates in SQLite from before synthetic KI fix)
if (startHeight === 0) {
await storage.clear();
console.log(' Database cleared for fresh sync');
}
await storage.setSyncHeight(startHeight);
// Generate subaddress maps (matching C++ wallet lookahead: 50 major x 200 minor)
@@ -405,12 +411,17 @@ async function runIntegrationTest() {
// Track batch size adaptation
let lastBatchSize = null;
sync.on('batchComplete', (data) => {
// Only log when batch size changes
if (lastBatchSize !== data.batchSize) {
const direction = lastBatchSize === null ? 'initial' : (data.batchSize > lastBatchSize ? '↑' : '↓');
console.log(` [Batch] ${direction} size=${data.batchSize} (${data.elapsed}ms, ${data.blocksPerSec.toFixed(1)} blk/s)`);
lastBatchSize = data.batchSize;
}
const direction = lastBatchSize === null ? 'initial' : (data.batchSize > lastBatchSize ? '↑' : (data.batchSize < lastBatchSize ? '↓' : '='));
const t = data.timing;
const b = data.breakdown;
const timingStr = t
? ` [${t.path} hdr=${t.headerMs}ms fetch=${t.fetchMs}ms proc=${t.processMs}ms]`
: '';
const breakdownStr = b
? `\n parse=${b.parse}ms hash=${b.hash}ms miner=${b.miner}ms proto=${b.proto}ms regular=${b.regular}ms(${b.nRegularTxs}tx) flush=${b.flush}ms`
: '';
console.log(` [Batch] ${direction} size=${data.batchSize} n=${data.blocksProcessed} (${data.elapsed}ms, ${data.blocksPerSec.toFixed(1)} blk/s)${timingStr}${breakdownStr}`);
lastBatchSize = data.batchSize;
});
// Debug: sample a few blocks to see transaction structure
@@ -461,42 +472,50 @@ async function runIntegrationTest() {
const transactions = await storage.getTransactions();
const syncHeight = await storage.getSyncHeight();
// Calculate balance with locked/unlocked split
let balance = 0n;
let unlockedBalance = 0n;
// ── Per-asset-type balance breakdown ─────────────────────────────────
// SAL and SAL1 are the same underlying asset (SAL1 is post-HF6 rename).
// Compute both individually and combined for proper comparison with C++ wallet.
const balanceByAsset = {};
let syntheticKiCount = 0;
let syntheticKiBalance = 0n;
let unspentCount = 0;
let unlockedCount = 0;
if (storageBackend === 'ffi' && storage.getBalance) {
// Rust-computed balance — single FFI call, no output round-trip
const bal = storage.getBalance({ currentHeight: syncHeight, assetType: 'SAL1' });
balance = bal.balance;
unlockedBalance = bal.unlockedBalance;
// Count unspent outputs for display
for (const output of outputs) {
if (!output.isSpent) {
unspentCount++;
if (output.isUnlocked?.(syncHeight) ?? true) unlockedCount++;
}
for (const output of outputs) {
if (output.isSpent) continue;
const at = output.assetType || 'SAL';
const amount = typeof output.amount === 'bigint' ? output.amount : BigInt(output.amount);
if (!balanceByAsset[at]) balanceByAsset[at] = { count: 0, total: 0n, unlocked: 0n, unlockedCount: 0 };
balanceByAsset[at].count++;
balanceByAsset[at].total += amount;
unspentCount++;
const unlocked = output.isUnlocked?.(syncHeight) ?? true;
if (unlocked) {
balanceByAsset[at].unlocked += amount;
balanceByAsset[at].unlockedCount++;
}
} else {
// Manual balance computation for MemoryStorage
for (const output of outputs) {
if (!output.isSpent) {
const amount = typeof output.amount === 'bigint' ? output.amount : BigInt(output.amount);
balance += amount;
unspentCount++;
// Use WalletOutput.isUnlocked() which correctly handles both
// string ('miner','protocol') and numeric (1,2) txType values
if (output.isUnlocked(syncHeight)) {
unlockedBalance += amount;
unlockedCount++;
}
}
// Track synthetic key images (outputs where KI generation failed)
if (output.keyImage && output.keyImage.startsWith('00') && output.keyImage.length > 64) {
syntheticKiCount++;
syntheticKiBalance += amount;
}
}
const lockedBalance = balance - unlockedBalance;
// Combined SAL+SAL1 balance (same underlying asset)
let salBalance = (balanceByAsset['SAL']?.total || 0n) + (balanceByAsset['SAL1']?.total || 0n);
const salUnlocked = (balanceByAsset['SAL']?.unlocked || 0n) + (balanceByAsset['SAL1']?.unlocked || 0n);
const salCount = (balanceByAsset['SAL']?.count || 0) + (balanceByAsset['SAL1']?.count || 0);
// Include locked stakes in total balance (matches C++ wallet behavior)
let stakedBalance = 0n;
if (typeof storage.getStakes === 'function') {
const lockedStakes = await storage.getStakes({ status: 'locked' });
for (const s of lockedStakes) {
stakedBalance += typeof s.amountStaked === 'bigint' ? s.amountStaked : BigInt(s.amountStaked || 0);
}
salBalance += stakedBalance;
}
const salLocked = salBalance - salUnlocked;
// Report results
console.log('\n' + '='.repeat(60));
@@ -508,33 +527,65 @@ async function runIntegrationTest() {
console.log(`Final sync height: ${syncHeight}`);
console.log('');
console.log(`Outputs found: ${outputs.length}`);
console.log(`Unspent outputs: ${unspentCount} (${unlockedCount} unlocked, ${unspentCount - unlockedCount} locked)`);
console.log(`Unspent outputs: ${unspentCount}`);
console.log(`Transactions: ${transactions.length}`);
console.log('');
console.log(`Total balance: ${balance} atomic (${(Number(balance) / 1e8).toFixed(8)} SAL1)`);
console.log(`Unlocked balance: ${unlockedBalance} atomic (${(Number(unlockedBalance) / 1e8).toFixed(8)} SAL1)`);
console.log(`Locked balance: ${lockedBalance} atomic (${(Number(lockedBalance) / 1e8).toFixed(8)} SAL1)`);
// === DIAGNOSTIC: null keyImage outputs ===
const nullKiOutput = await storage.getOutput(null);
const nullKiUndefined = await storage.getOutput(undefined);
console.log(`\n--- KEY IMAGE DIAGNOSTIC ---`);
console.log(`Output stored under null key: ${nullKiOutput ? `YES h=${nullKiOutput.blockHeight} amt=${(Number(nullKiOutput.amount)/1e8).toFixed(8)}` : 'none'}`);
console.log(`Output stored under undefined key: ${nullKiUndefined ? `YES h=${nullKiUndefined.blockHeight} amt=${(Number(nullKiUndefined.amount)/1e8).toFixed(8)}` : 'none'}`);
let nullKiCount = 0;
let nullKiUnspentTotal = 0n;
for (const o of outputs) {
if (!o.keyImage) {
nullKiCount++;
if (!o.isSpent) {
nullKiUnspentTotal += BigInt(o.amount);
console.log(` null-ki UNSPENT: h=${o.blockHeight} amt=${(Number(o.amount)/1e8).toFixed(8)} carrot=${o.isCarrot} type=${o.txType} tx=${o.txHash?.slice(0,16)}`);
}
}
console.log('--- Balance by Asset Type ---');
for (const [at, data] of Object.entries(balanceByAsset).sort()) {
console.log(` ${at}: ${data.count} outputs, ${(Number(data.total) / 1e8).toFixed(8)} total (${(Number(data.unlocked) / 1e8).toFixed(8)} unlocked, ${data.unlockedCount}/${data.count} outputs)`);
}
console.log(`Total outputs with null keyImage: ${nullKiCount}`);
console.log(`Unspent balance from null-ki outputs: ${(Number(nullKiUnspentTotal)/1e8).toFixed(8)} SAL`);
console.log('');
console.log(`Combined SAL+SAL1: ${(Number(salBalance) / 1e8).toFixed(8)} (${salCount} outputs)`);
console.log(` Unlocked: ${(Number(salUnlocked) / 1e8).toFixed(8)}`);
console.log(` Locked: ${(Number(salLocked) / 1e8).toFixed(8)}`);
if (stakedBalance > 0n) {
console.log(` Staked: ${(Number(stakedBalance) / 1e8).toFixed(8)} (included in locked)`)
}
if (syntheticKiCount > 0) {
console.log(`\nSynthetic key images: ${syntheticKiCount} outputs, ${(Number(syntheticKiBalance) / 1e8).toFixed(8)} total`);
console.log(` (These outputs had key image generation failures — balance may be inflated)`);
}
// === DIAGNOSTIC: key image health ===
console.log(`\n--- KEY IMAGE DIAGNOSTIC ---`);
let nullKiCount = 0;
let synthKiCount = 0;
let realKiCount = 0;
let synthUnspentBalance = 0n;
for (const o of outputs) {
if (!o.keyImage) nullKiCount++;
else if (o.keyImage.startsWith('00') && o.keyImage.length > 64) {
synthKiCount++;
if (!o.isSpent) synthUnspentBalance += typeof o.amount === 'bigint' ? o.amount : BigInt(o.amount || 0);
}
else realKiCount++;
}
console.log(`Real key images: ${realKiCount}`);
console.log(`Synthetic key images: ${synthKiCount} (KI generation failed, cannot track spends)`);
console.log(` Synth unspent bal: ${(Number(synthUnspentBalance) / 1e8).toFixed(8)}`);
console.log(`Null key images: ${nullKiCount} (legacy rows from before synthetic KI fix)`);
// === DIAGNOSTIC: unspent by txType + carrot ===
const unspentByType = {};
const unspentByCarrot = { cn: { count: 0, total: 0n }, carrot: { count: 0, total: 0n } };
for (const o of outputs) {
if (o.isSpent) continue;
const amt = typeof o.amount === 'bigint' ? o.amount : BigInt(o.amount || 0);
const tt = o.txType ?? 'unknown';
if (!unspentByType[tt]) unspentByType[tt] = { count: 0, total: 0n };
unspentByType[tt].count++;
unspentByType[tt].total += amt;
const bucket = o.isCarrot ? unspentByCarrot.carrot : unspentByCarrot.cn;
bucket.count++;
bucket.total += amt;
}
console.log(`\n--- UNSPENT BY TX TYPE ---`);
for (const [tt, data] of Object.entries(unspentByType).sort()) {
console.log(` type=${tt}: ${data.count} outputs, ${(Number(data.total) / 1e8).toFixed(8)}`);
}
console.log(`\n--- UNSPENT BY FORMAT ---`);
console.log(` CryptoNote: ${unspentByCarrot.cn.count} outputs, ${(Number(unspentByCarrot.cn.total) / 1e8).toFixed(8)}`);
console.log(` CARROT: ${unspentByCarrot.carrot.count} outputs, ${(Number(unspentByCarrot.carrot.total) / 1e8).toFixed(8)}`);
// === DIAGNOSTIC: all unspent outputs ===
const unspentOutputs = outputs.filter(o => !o.isSpent);
@@ -650,13 +701,13 @@ async function runIntegrationTest() {
}
}
// Verify expected balance
// Verify expected balance (uses combined SAL+SAL1 balance)
if (EXPECTED_BALANCE !== null) {
console.log('\n--- Verification ---');
if (balance >= EXPECTED_BALANCE) {
console.log(`✓ Balance ${balance} >= expected ${EXPECTED_BALANCE}`);
if (salBalance >= EXPECTED_BALANCE) {
console.log(`✓ Balance ${(Number(salBalance) / 1e8).toFixed(8)} >= expected ${(Number(EXPECTED_BALANCE) / 1e8).toFixed(8)}`);
} else {
console.log(`✗ Balance ${balance} < expected ${EXPECTED_BALANCE}`);
console.log(`✗ Balance ${(Number(salBalance) / 1e8).toFixed(8)} < expected ${(Number(EXPECTED_BALANCE) / 1e8).toFixed(8)}`);
process.exit(1);
}
}
+1 -3
View File
@@ -23,10 +23,8 @@ import {
TXOUT_TYPE
} from '../src/transaction.js';
import { scalarMultBase } from '../src/crypto/index.js';
import { scalarMultBase, generateKeyImage, initCrypto } from '../src/crypto/index.js';
import { bytesToHex, hexToBytes } from '../src/address.js';
import { generateKeyImage } from '../src/keyimage.js';
import { initCrypto } from '../src/crypto/index.js';
await initCrypto();
+1 -1
View File
@@ -64,7 +64,7 @@ import {
getGeneratorG
} from '../src/crypto/index.js';
import { hashToPoint, generateKeyImage } from '../src/keyimage.js';
import { hashToPoint, generateKeyImage } from '../src/crypto/index.js';
import { deriveKeys } from '../src/carrot.js';
import { generateSeed } from '../src/carrot.js';
import { bytesToHex, hexToBytes } from '../src/address.js';