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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('"', "\\\"")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+3
-60
@@ -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
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -42,7 +42,7 @@ export {
|
||||
// Key derivation
|
||||
argon2id,
|
||||
// X25519
|
||||
x25519ScalarMult,
|
||||
x25519ScalarMult, edwardsToMontgomeryU,
|
||||
// CARROT key derivation
|
||||
computeCarrotSpendPubkey, computeCarrotAccountViewPubkey,
|
||||
computeCarrotMainAddressViewPubkey,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Vendored
+27
@@ -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];
|
||||
|
||||
@@ -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
@@ -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
@@ -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: -x² + y² = 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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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); });
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user