Move CARROT output scanning to Rust FFI to eliminate JS↔Rust hairpinning
Route x25519ScalarMult through the crypto provider backend instead of using a pure-JS BigInt implementation inline in carrot-scanning.js. Add a full 7-step CARROT scanner in Rust (carrot_scan.rs) with two FFI entry points (standard X25519 ECDH and self-send paths), so wallet-sync can execute the entire scan in a single native call when the FFI backend is active, falling back to the existing JS pipeline otherwise.
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
//! CARROT output scanning — full 7-step pipeline in Rust.
|
||||
//!
|
||||
//! Moves the entire scan from JS (which hairpins every crypto op through FFI)
|
||||
//! into a single native call. Two entry points:
|
||||
//!
|
||||
//! * `scan_carrot_output` — standard path: X25519 ECDH → core steps 2-7
|
||||
//! * `scan_carrot_internal_output` — self-send path: viewBalanceSecret used
|
||||
//! directly as s_sr_unctx (skips X25519)
|
||||
|
||||
use curve25519_dalek::edwards::{CompressedEdwardsY, EdwardsPoint};
|
||||
use curve25519_dalek::scalar::Scalar;
|
||||
use curve25519_dalek::traits::VartimeMultiscalarMul;
|
||||
|
||||
use crate::to32;
|
||||
|
||||
// T generator — same bytes as tclsag.rs and JS side
|
||||
pub const T_BYTES: [u8; 32] = [
|
||||
0x96, 0x6f, 0xc6, 0x6b, 0x82, 0xcd, 0x56, 0xcf,
|
||||
0x85, 0xea, 0xec, 0x80, 0x1c, 0x42, 0x84, 0x5f,
|
||||
0x5f, 0x40, 0x88, 0x78, 0xd1, 0x56, 0x1e, 0x00,
|
||||
0xd3, 0xd7, 0xde, 0xd2, 0x79, 0x4d, 0x09, 0x4f,
|
||||
];
|
||||
|
||||
/// H generator for Pedersen commitments (same as lib.rs)
|
||||
const H_POINT_BYTES: [u8; 32] = crate::H_POINT_BYTES;
|
||||
|
||||
// ─── Domain separators (matching config.h) ──────────────────────────────────
|
||||
|
||||
const DOMAIN_VIEW_TAG: &[u8] = b"Carrot view tag";
|
||||
const DOMAIN_SENDER_RECEIVER_SECRET: &[u8] = b"Carrot sender-receiver secret";
|
||||
const DOMAIN_COMMITMENT_MASK: &[u8] = b"Carrot commitment mask";
|
||||
const DOMAIN_EXTENSION_G: &[u8] = b"Carrot key extension G";
|
||||
const DOMAIN_EXTENSION_T: &[u8] = b"Carrot key extension T";
|
||||
const DOMAIN_ENCRYPTION_MASK_AMOUNT: &[u8] = b"Carrot encryption mask a";
|
||||
|
||||
// ─── Result ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Result of a successful CARROT scan.
|
||||
pub struct CarrotScanResult {
|
||||
pub amount: u64,
|
||||
pub mask: [u8; 32],
|
||||
/// 0 = PAYMENT, 1 = CHANGE
|
||||
pub enote_type: u8,
|
||||
pub shared_secret: [u8; 32],
|
||||
pub address_spend_pubkey: [u8; 32],
|
||||
pub subaddress_major: u32,
|
||||
pub subaddress_minor: u32,
|
||||
pub is_main_address: bool,
|
||||
}
|
||||
|
||||
/// Serialize result to JSON with hex-encoded byte arrays.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl CarrotScanResult {
|
||||
pub fn to_json(&self) -> Vec<u8> {
|
||||
let json = serde_json::json!({
|
||||
"amount": self.amount,
|
||||
"mask": hex::encode(self.mask),
|
||||
"enote_type": self.enote_type,
|
||||
"shared_secret": hex::encode(self.shared_secret),
|
||||
"address_spend_pubkey": hex::encode(self.address_spend_pubkey),
|
||||
"subaddress_major": self.subaddress_major,
|
||||
"subaddress_minor": self.subaddress_minor,
|
||||
"is_main_address": self.is_main_address,
|
||||
});
|
||||
serde_json::to_vec(&json).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Transcript builder (SpFixedTranscript) ─────────────────────────────────
|
||||
|
||||
/// Build `[domain_len_byte] + domain + data...`
|
||||
fn build_transcript(domain: &[u8], data: &[&[u8]]) -> Vec<u8> {
|
||||
let total: usize = 1 + domain.len() + data.iter().map(|d| d.len()).sum::<usize>();
|
||||
let mut buf = Vec::with_capacity(total);
|
||||
buf.push(domain.len() as u8);
|
||||
buf.extend_from_slice(domain);
|
||||
for d in data {
|
||||
buf.extend_from_slice(d);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// Keyed blake2b with given output length.
|
||||
fn blake2b_keyed(transcript: &[u8], out_len: usize, key: &[u8]) -> Vec<u8> {
|
||||
blake2b_simd::Params::new()
|
||||
.hash_length(out_len)
|
||||
.key(key)
|
||||
.hash(transcript)
|
||||
.as_bytes()
|
||||
.to_vec()
|
||||
}
|
||||
|
||||
/// H_n: blake2b 64 bytes keyed, then sc_reduce to 32-byte scalar.
|
||||
fn derive_scalar(key: &[u8], domain: &[u8], data: &[&[u8]]) -> Scalar {
|
||||
let transcript = build_transcript(domain, data);
|
||||
let hash64 = blake2b_keyed(&transcript, 64, key);
|
||||
let mut wide = [0u8; 64];
|
||||
wide.copy_from_slice(&hash64);
|
||||
Scalar::from_bytes_mod_order_wide(&wide)
|
||||
}
|
||||
|
||||
/// H_32: blake2b 32 bytes keyed.
|
||||
fn derive_bytes_32(key: &[u8], domain: &[u8], data: &[&[u8]]) -> [u8; 32] {
|
||||
let transcript = build_transcript(domain, data);
|
||||
let hash = blake2b_keyed(&transcript, 32, key);
|
||||
to32(&hash)
|
||||
}
|
||||
|
||||
/// H_8: blake2b 8 bytes keyed.
|
||||
fn derive_bytes_8(key: &[u8], domain: &[u8], data: &[&[u8]]) -> [u8; 8] {
|
||||
let transcript = build_transcript(domain, data);
|
||||
let hash = blake2b_keyed(&transcript, 8, key);
|
||||
let mut out = [0u8; 8];
|
||||
out.copy_from_slice(&hash[..8]);
|
||||
out
|
||||
}
|
||||
|
||||
// ─── Core scanning steps ────────────────────────────────────────────────────
|
||||
|
||||
/// Step 2: View tag test (3-byte fast filter).
|
||||
fn compute_view_tag(s_sr_unctx: &[u8; 32], input_context: &[u8], ko: &[u8; 32]) -> [u8; 3] {
|
||||
let transcript = build_transcript(DOMAIN_VIEW_TAG, &[input_context, ko]);
|
||||
let hash = blake2b_keyed(&transcript, 3, s_sr_unctx);
|
||||
[hash[0], hash[1], hash[2]]
|
||||
}
|
||||
|
||||
/// Step 3: Contextualized shared secret.
|
||||
fn make_sender_receiver_secret(
|
||||
s_sr_unctx: &[u8; 32],
|
||||
d_e: &[u8; 32],
|
||||
input_context: &[u8],
|
||||
) -> [u8; 32] {
|
||||
derive_bytes_32(s_sr_unctx, DOMAIN_SENDER_RECEIVER_SECRET, &[d_e, input_context])
|
||||
}
|
||||
|
||||
/// Step 4a: k^o_g = H_n[s_sr_ctx]("Carrot key extension G", C_a)
|
||||
fn derive_extension_g(s_sr_ctx: &[u8; 32], commitment: &[u8; 32]) -> Scalar {
|
||||
derive_scalar(s_sr_ctx, DOMAIN_EXTENSION_G, &[commitment])
|
||||
}
|
||||
|
||||
/// Step 4b: k^o_t = H_n[s_sr_ctx]("Carrot key extension T", C_a)
|
||||
fn derive_extension_t(s_sr_ctx: &[u8; 32], commitment: &[u8; 32]) -> Scalar {
|
||||
derive_scalar(s_sr_ctx, DOMAIN_EXTENSION_T, &[commitment])
|
||||
}
|
||||
|
||||
/// Step 4: Recover address spend pubkey.
|
||||
/// K^j_s = Ko - (k^o_g * G + k^o_t * T)
|
||||
fn recover_address_spend_pubkey(
|
||||
ko: &[u8; 32],
|
||||
s_sr_ctx: &[u8; 32],
|
||||
commitment: &[u8; 32],
|
||||
) -> Option<[u8; 32]> {
|
||||
let k_g = derive_extension_g(s_sr_ctx, commitment);
|
||||
let k_t = derive_extension_t(s_sr_ctx, commitment);
|
||||
|
||||
let t_point = CompressedEdwardsY(T_BYTES).decompress()?;
|
||||
let ko_point = CompressedEdwardsY(*ko).decompress()?;
|
||||
|
||||
// K^o_ext = k_g * G + k_t * T
|
||||
let ext = EdwardsPoint::vartime_multiscalar_mul(
|
||||
&[k_g, k_t],
|
||||
&[curve25519_dalek::constants::ED25519_BASEPOINT_POINT, t_point],
|
||||
);
|
||||
|
||||
// K^j_s = Ko - ext
|
||||
let recovered = ko_point - ext;
|
||||
Some(recovered.compress().to_bytes())
|
||||
}
|
||||
|
||||
/// Step 6: Decrypt amount.
|
||||
fn decrypt_amount(
|
||||
enc_amount: &[u8; 8],
|
||||
s_sr_ctx: &[u8; 32],
|
||||
ko: &[u8; 32],
|
||||
) -> u64 {
|
||||
let mask = derive_bytes_8(s_sr_ctx, DOMAIN_ENCRYPTION_MASK_AMOUNT, &[ko]);
|
||||
let mut decrypted = [0u8; 8];
|
||||
for i in 0..8 {
|
||||
decrypted[i] = enc_amount[i] ^ mask[i];
|
||||
}
|
||||
u64::from_le_bytes(decrypted)
|
||||
}
|
||||
|
||||
/// Step 7a: Derive commitment mask.
|
||||
fn derive_commitment_mask(
|
||||
s_sr_ctx: &[u8; 32],
|
||||
amount: u64,
|
||||
address_spend_pubkey: &[u8; 32],
|
||||
enote_type: u8,
|
||||
) -> Scalar {
|
||||
let amount_bytes = amount.to_le_bytes();
|
||||
let type_byte = [enote_type];
|
||||
derive_scalar(
|
||||
s_sr_ctx,
|
||||
DOMAIN_COMMITMENT_MASK,
|
||||
&[&amount_bytes, address_spend_pubkey, &type_byte],
|
||||
)
|
||||
}
|
||||
|
||||
/// Step 7b: Compute Pedersen commitment and compare.
|
||||
fn pedersen_commit(amount: u64, mask: &Scalar) -> [u8; 32] {
|
||||
let h = CompressedEdwardsY(H_POINT_BYTES).decompress().expect("invalid H");
|
||||
let mut amount_bytes = [0u8; 32];
|
||||
let le = amount.to_le_bytes();
|
||||
amount_bytes[..8].copy_from_slice(&le);
|
||||
let amount_scalar = Scalar::from_bytes_mod_order(amount_bytes);
|
||||
EdwardsPoint::vartime_multiscalar_mul(
|
||||
&[*mask, amount_scalar],
|
||||
&[curve25519_dalek::constants::ED25519_BASEPOINT_POINT, h],
|
||||
)
|
||||
.compress()
|
||||
.to_bytes()
|
||||
}
|
||||
|
||||
// ─── Core scan (steps 2-7) ──────────────────────────────────────────────────
|
||||
|
||||
/// Run the core CARROT scan steps 2-7 given an uncontextualized shared secret.
|
||||
///
|
||||
/// `subaddress_map`: slice of (32-byte spend pubkey, major, minor) tuples.
|
||||
/// `account_spend_pubkey`: the main account K_s.
|
||||
/// `clear_text_amount`: if Some, use this instead of decrypting (coinbase).
|
||||
fn scan_core(
|
||||
s_sr_unctx: &[u8; 32],
|
||||
ko: &[u8; 32],
|
||||
view_tag: &[u8; 3],
|
||||
d_e: &[u8; 32],
|
||||
enc_amount: &[u8; 8],
|
||||
commitment: Option<&[u8; 32]>,
|
||||
account_spend_pubkey: &[u8; 32],
|
||||
input_context: &[u8],
|
||||
subaddress_map: &[([u8; 32], u32, u32)],
|
||||
clear_text_amount: Option<u64>,
|
||||
) -> Option<CarrotScanResult> {
|
||||
// Step 2: View tag test
|
||||
let expected_vt = compute_view_tag(s_sr_unctx, input_context, ko);
|
||||
if expected_vt != *view_tag {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Step 3: Contextualized shared secret
|
||||
let s_sr_ctx = make_sender_receiver_secret(s_sr_unctx, d_e, input_context);
|
||||
|
||||
// Step 4: Recover address spend pubkey
|
||||
let commit_bytes = commitment.copied().unwrap_or([0u8; 32]);
|
||||
let recovered = recover_address_spend_pubkey(ko, &s_sr_ctx, &commit_bytes)?;
|
||||
|
||||
// Step 5: Address matching
|
||||
let mut is_main_address = false;
|
||||
let mut major = 0u32;
|
||||
let mut minor = 0u32;
|
||||
|
||||
if recovered == *account_spend_pubkey {
|
||||
is_main_address = true;
|
||||
} else {
|
||||
let mut found = false;
|
||||
for (pubkey, maj, min) in subaddress_map {
|
||||
if recovered == *pubkey {
|
||||
major = *maj;
|
||||
minor = *min;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Decrypt amount
|
||||
let amount = if let Some(ct) = clear_text_amount {
|
||||
ct
|
||||
} else {
|
||||
decrypt_amount(enc_amount, &s_sr_ctx, ko)
|
||||
};
|
||||
|
||||
// Step 7: Derive commitment mask, try PAYMENT(0) then CHANGE(1)
|
||||
let mask_payment = derive_commitment_mask(&s_sr_ctx, amount, &recovered, 0);
|
||||
let (mask, enote_type) = if let Some(c) = commitment {
|
||||
let computed_payment = pedersen_commit(amount, &mask_payment);
|
||||
if computed_payment == *c {
|
||||
(mask_payment, 0u8)
|
||||
} else {
|
||||
let mask_change = derive_commitment_mask(&s_sr_ctx, amount, &recovered, 1);
|
||||
let computed_change = pedersen_commit(amount, &mask_change);
|
||||
if computed_change == *c {
|
||||
(mask_change, 1u8)
|
||||
} else {
|
||||
// Neither matched — fallback to PAYMENT (coinbase-like)
|
||||
(mask_payment, 0u8)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No commitment to verify (coinbase) — PAYMENT
|
||||
(mask_payment, 0u8)
|
||||
};
|
||||
|
||||
Some(CarrotScanResult {
|
||||
amount,
|
||||
mask: mask.to_bytes(),
|
||||
enote_type,
|
||||
shared_secret: s_sr_ctx,
|
||||
address_spend_pubkey: recovered,
|
||||
subaddress_major: major,
|
||||
subaddress_minor: minor,
|
||||
is_main_address,
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Public entry points ────────────────────────────────────────────────────
|
||||
|
||||
/// Standard CARROT scan: X25519 ECDH then core steps 2-7.
|
||||
pub fn scan_carrot_output(
|
||||
ko: &[u8; 32],
|
||||
view_tag: &[u8; 3],
|
||||
d_e: &[u8; 32],
|
||||
enc_amount: &[u8; 8],
|
||||
commitment: Option<&[u8; 32]>,
|
||||
k_vi: &[u8; 32],
|
||||
account_spend_pubkey: &[u8; 32],
|
||||
input_context: &[u8],
|
||||
subaddress_map: &[([u8; 32], u32, u32)],
|
||||
clear_text_amount: Option<u64>,
|
||||
) -> Option<CarrotScanResult> {
|
||||
// Step 1: X25519 ECDH — s_sr_unctx = k_vi * D_e
|
||||
let mut clamped = *k_vi;
|
||||
clamped[31] &= 0x7F;
|
||||
let s_sr_unctx = to32(&crate::x25519::montgomery_ladder(&clamped, d_e));
|
||||
|
||||
scan_core(
|
||||
&s_sr_unctx,
|
||||
ko, view_tag, d_e, enc_amount, commitment,
|
||||
account_spend_pubkey, input_context, subaddress_map,
|
||||
clear_text_amount,
|
||||
)
|
||||
}
|
||||
|
||||
/// Self-send CARROT scan: viewBalanceSecret used directly as s_sr_unctx.
|
||||
pub fn scan_carrot_internal_output(
|
||||
ko: &[u8; 32],
|
||||
view_tag: &[u8; 3],
|
||||
d_e: &[u8; 32],
|
||||
enc_amount: &[u8; 8],
|
||||
commitment: Option<&[u8; 32]>,
|
||||
view_balance_secret: &[u8; 32],
|
||||
account_spend_pubkey: &[u8; 32],
|
||||
input_context: &[u8],
|
||||
subaddress_map: &[([u8; 32], u32, u32)],
|
||||
clear_text_amount: Option<u64>,
|
||||
) -> Option<CarrotScanResult> {
|
||||
scan_core(
|
||||
view_balance_secret,
|
||||
ko, view_tag, d_e, enc_amount, commitment,
|
||||
account_spend_pubkey, input_context, subaddress_map,
|
||||
clear_text_amount,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_view_tag_deterministic() {
|
||||
let s_sr = [0x42u8; 32];
|
||||
let input_ctx = [0x52u8; 33]; // 'R' + 32 zero bytes
|
||||
let ko = [0x58u8; 32]; // G point
|
||||
let vt1 = compute_view_tag(&s_sr, &input_ctx, &ko);
|
||||
let vt2 = compute_view_tag(&s_sr, &input_ctx, &ko);
|
||||
assert_eq!(vt1, vt2);
|
||||
assert_ne!(vt1, [0, 0, 0]); // extremely unlikely to be all-zero
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sender_receiver_secret_deterministic() {
|
||||
let s_sr_unctx = [0x01u8; 32];
|
||||
let d_e = [0x09u8; 32];
|
||||
let input_ctx = [0x43u8; 33];
|
||||
let s1 = make_sender_receiver_secret(&s_sr_unctx, &d_e, &input_ctx);
|
||||
let s2 = make_sender_receiver_secret(&s_sr_unctx, &d_e, &input_ctx);
|
||||
assert_eq!(s1, s2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_amount_roundtrip() {
|
||||
let s_sr_ctx = [0x55u8; 32];
|
||||
let ko = [0x58u8; 32];
|
||||
let amount: u64 = 123456789;
|
||||
// Encrypt
|
||||
let mask = derive_bytes_8(&s_sr_ctx, DOMAIN_ENCRYPTION_MASK_AMOUNT, &[&ko]);
|
||||
let amount_le = amount.to_le_bytes();
|
||||
let mut enc = [0u8; 8];
|
||||
for i in 0..8 {
|
||||
enc[i] = amount_le[i] ^ mask[i];
|
||||
}
|
||||
// Decrypt
|
||||
let decrypted = decrypt_amount(&enc, &s_sr_ctx, &ko);
|
||||
assert_eq!(decrypted, amount);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_commitment_mask_differs_by_enote_type() {
|
||||
let s_sr_ctx = [0x33u8; 32];
|
||||
let amount = 1000u64;
|
||||
let addr = [0x58u8; 32];
|
||||
let mask0 = derive_commitment_mask(&s_sr_ctx, amount, &addr, 0);
|
||||
let mask1 = derive_commitment_mask(&s_sr_ctx, amount, &addr, 1);
|
||||
assert_ne!(mask0.to_bytes(), mask1.to_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pedersen_commit_deterministic() {
|
||||
let mask = Scalar::from(42u64);
|
||||
let c1 = pedersen_commit(100, &mask);
|
||||
let c2 = pedersen_commit(100, &mask);
|
||||
assert_eq!(c1, c2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_view_tag_mismatch_returns_none() {
|
||||
let s_sr = [0x42u8; 32];
|
||||
let ko = [0x58u8; 32]; // G
|
||||
let view_tag = [0xff, 0xff, 0xff]; // wrong
|
||||
let d_e = [9u8; 32];
|
||||
let enc_amount = [0u8; 8];
|
||||
let ks = [0x58u8; 32];
|
||||
let input_ctx = [0x52u8; 33];
|
||||
|
||||
let result = scan_carrot_output(
|
||||
&ko, &view_tag, &d_e, &enc_amount, None,
|
||||
&s_sr, &ks, &input_ctx, &[], None,
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transcript_format() {
|
||||
let t = build_transcript(b"test", &[&[1, 2], &[3, 4, 5]]);
|
||||
assert_eq!(t.len(), 1 + 4 + 2 + 3);
|
||||
assert_eq!(t[0], 4); // domain length
|
||||
assert_eq!(&t[1..5], b"test");
|
||||
assert_eq!(&t[5..7], &[1, 2]);
|
||||
assert_eq!(&t[7..10], &[3, 4, 5]);
|
||||
}
|
||||
}
|
||||
@@ -854,6 +854,173 @@ pub unsafe extern "C" fn salvium_bulletproof_plus_verify(
|
||||
})
|
||||
}
|
||||
|
||||
// ─── CARROT Output Scanning ─────────────────────────────────────────────────
|
||||
|
||||
/// CARROT output scan — standard (X25519 ECDH) path.
|
||||
///
|
||||
/// Fixed-size inputs:
|
||||
/// ko: 32, view_tag: 3, d_e: 32, enc_amount: 8, commitment: 32 (or null),
|
||||
/// k_vi: 32, account_spend_pubkey: 32, input_context_len (usize),
|
||||
/// clear_text_amount: u64 (u64::MAX means "not provided")
|
||||
/// Variable inputs:
|
||||
/// input_context: input_context_len bytes
|
||||
/// subaddr_data: n_sub * 40 bytes (32-byte key + u32 major LE + u32 minor LE)
|
||||
/// Output:
|
||||
/// JSON result via Rust-allocated buffer (freed with salvium_storage_free_buf).
|
||||
///
|
||||
/// Returns: 1 = owned, 0 = not owned, -1 = error
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn salvium_carrot_scan_output(
|
||||
ko: *const u8,
|
||||
view_tag: *const u8,
|
||||
d_e: *const u8,
|
||||
enc_amount: *const u8,
|
||||
commitment: *const u8, // nullable
|
||||
k_vi: *const u8,
|
||||
account_spend_pubkey: *const u8,
|
||||
input_context: *const u8,
|
||||
input_context_len: usize,
|
||||
clear_text_amount: u64, // u64::MAX = not provided
|
||||
subaddr_data: *const u8, // n * 40 bytes
|
||||
n_sub: u32,
|
||||
out_ptr: *mut *mut u8,
|
||||
out_len: *mut usize,
|
||||
) -> i32 {
|
||||
catch_ffi(|| {
|
||||
let ko_arr = crate::to32(slice::from_raw_parts(ko, 32));
|
||||
let vt: [u8; 3] = {
|
||||
let s = slice::from_raw_parts(view_tag, 3);
|
||||
[s[0], s[1], s[2]]
|
||||
};
|
||||
let d_e_arr = crate::to32(slice::from_raw_parts(d_e, 32));
|
||||
let enc_amt: [u8; 8] = {
|
||||
let s = slice::from_raw_parts(enc_amount, 8);
|
||||
let mut a = [0u8; 8];
|
||||
a.copy_from_slice(s);
|
||||
a
|
||||
};
|
||||
let commit_opt = if commitment.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(crate::to32(slice::from_raw_parts(commitment, 32)))
|
||||
};
|
||||
let k_vi_arr = crate::to32(slice::from_raw_parts(k_vi, 32));
|
||||
let ks_arr = crate::to32(slice::from_raw_parts(account_spend_pubkey, 32));
|
||||
let ic = slice::from_raw_parts(input_context, input_context_len);
|
||||
let ct_amount = if clear_text_amount == u64::MAX { None } else { Some(clear_text_amount) };
|
||||
|
||||
// Parse subaddress map: each entry is 40 bytes (32 key + 4 major + 4 minor)
|
||||
let n = n_sub as usize;
|
||||
let sub_slice = if n > 0 { slice::from_raw_parts(subaddr_data, n * 40) } else { &[] };
|
||||
let subaddrs: Vec<([u8; 32], u32, u32)> = (0..n).map(|i| {
|
||||
let base = i * 40;
|
||||
let key = crate::to32(&sub_slice[base..base + 32]);
|
||||
let major = u32::from_le_bytes([
|
||||
sub_slice[base + 32], sub_slice[base + 33],
|
||||
sub_slice[base + 34], sub_slice[base + 35],
|
||||
]);
|
||||
let minor = u32::from_le_bytes([
|
||||
sub_slice[base + 36], sub_slice[base + 37],
|
||||
sub_slice[base + 38], sub_slice[base + 39],
|
||||
]);
|
||||
(key, major, minor)
|
||||
}).collect();
|
||||
|
||||
match crate::carrot_scan::scan_carrot_output(
|
||||
&ko_arr, &vt, &d_e_arr, &enc_amt, commit_opt.as_ref(),
|
||||
&k_vi_arr, &ks_arr, ic, &subaddrs, ct_amount,
|
||||
) {
|
||||
Some(result) => {
|
||||
let json = result.to_json();
|
||||
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;
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// CARROT output scan — self-send (internal) path.
|
||||
/// Same as salvium_carrot_scan_output but takes view_balance_secret
|
||||
/// instead of k_vi (view incoming key).
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn salvium_carrot_scan_internal(
|
||||
ko: *const u8,
|
||||
view_tag: *const u8,
|
||||
d_e: *const u8,
|
||||
enc_amount: *const u8,
|
||||
commitment: *const u8, // nullable
|
||||
view_balance_secret: *const u8,
|
||||
account_spend_pubkey: *const u8,
|
||||
input_context: *const u8,
|
||||
input_context_len: usize,
|
||||
clear_text_amount: u64, // u64::MAX = not provided
|
||||
subaddr_data: *const u8, // n * 40 bytes
|
||||
n_sub: u32,
|
||||
out_ptr: *mut *mut u8,
|
||||
out_len: *mut usize,
|
||||
) -> i32 {
|
||||
catch_ffi(|| {
|
||||
let ko_arr = crate::to32(slice::from_raw_parts(ko, 32));
|
||||
let vt: [u8; 3] = {
|
||||
let s = slice::from_raw_parts(view_tag, 3);
|
||||
[s[0], s[1], s[2]]
|
||||
};
|
||||
let d_e_arr = crate::to32(slice::from_raw_parts(d_e, 32));
|
||||
let enc_amt: [u8; 8] = {
|
||||
let s = slice::from_raw_parts(enc_amount, 8);
|
||||
let mut a = [0u8; 8];
|
||||
a.copy_from_slice(s);
|
||||
a
|
||||
};
|
||||
let commit_opt = if commitment.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(crate::to32(slice::from_raw_parts(commitment, 32)))
|
||||
};
|
||||
let vbs_arr = crate::to32(slice::from_raw_parts(view_balance_secret, 32));
|
||||
let ks_arr = crate::to32(slice::from_raw_parts(account_spend_pubkey, 32));
|
||||
let ic = slice::from_raw_parts(input_context, input_context_len);
|
||||
let ct_amount = if clear_text_amount == u64::MAX { None } else { Some(clear_text_amount) };
|
||||
|
||||
let n = n_sub as usize;
|
||||
let sub_slice = if n > 0 { slice::from_raw_parts(subaddr_data, n * 40) } else { &[] };
|
||||
let subaddrs: Vec<([u8; 32], u32, u32)> = (0..n).map(|i| {
|
||||
let base = i * 40;
|
||||
let key = crate::to32(&sub_slice[base..base + 32]);
|
||||
let major = u32::from_le_bytes([
|
||||
sub_slice[base + 32], sub_slice[base + 33],
|
||||
sub_slice[base + 34], sub_slice[base + 35],
|
||||
]);
|
||||
let minor = u32::from_le_bytes([
|
||||
sub_slice[base + 36], sub_slice[base + 37],
|
||||
sub_slice[base + 38], sub_slice[base + 39],
|
||||
]);
|
||||
(key, major, minor)
|
||||
}).collect();
|
||||
|
||||
match crate::carrot_scan::scan_carrot_internal_output(
|
||||
&ko_arr, &vt, &d_e_arr, &enc_amt, commit_opt.as_ref(),
|
||||
&vbs_arr, &ks_arr, ic, &subaddrs, ct_amount,
|
||||
) {
|
||||
Some(result) => {
|
||||
let json = result.to_json();
|
||||
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;
|
||||
1
|
||||
}
|
||||
None => 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Storage (SQLCipher) ────────────────────────────────────────────────────
|
||||
|
||||
/// Open/create an encrypted SQLite database.
|
||||
|
||||
@@ -7,6 +7,7 @@ use curve25519_dalek::traits::VartimeMultiscalarMul;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
mod x25519;
|
||||
pub mod carrot_scan;
|
||||
|
||||
pub(crate) mod elligator2;
|
||||
pub mod clsag;
|
||||
|
||||
+5
-100
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { hexToBytes, bytesToHex } from './address.js';
|
||||
import { blake2b, keccak256, scalarMultBase, scalarMultPoint, pointAddCompressed, hashToPoint, commit as pedersenCommit } from './crypto/index.js';
|
||||
import { blake2b, keccak256, scalarMultBase, scalarMultPoint, pointAddCompressed, hashToPoint, commit as pedersenCommit, x25519ScalarMult } from './crypto/index.js';
|
||||
|
||||
// Group order L for scalar reduction
|
||||
const L = (1n << 252n) + 27742317777372353535851937790883648493n;
|
||||
@@ -82,105 +82,10 @@ function modPow(base, exp, mod) {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* X25519 scalar multiplication on Montgomery curve
|
||||
* Computes scalar * u where u is a Montgomery u-coordinate
|
||||
*
|
||||
* This implements Salvium's mx25519, which differs from RFC 7748:
|
||||
* - Does NOT clear bits 0-2 of scalar (caller must do this if needed)
|
||||
* - Only clears bit 255
|
||||
* - Does NOT set bit 254
|
||||
* - Uses formula z2 = E * (BB + a24 * E) with a24 = 121666
|
||||
*
|
||||
* @param {Uint8Array} scalar - 32-byte scalar
|
||||
* @param {Uint8Array} u - 32-byte Montgomery u-coordinate
|
||||
* @returns {Uint8Array} 32-byte result u-coordinate
|
||||
*/
|
||||
export function x25519ScalarMult(scalar, u) {
|
||||
const p = 2n ** 255n - 19n;
|
||||
const a24 = 121666n; // Salvium uses 121666, not 121665
|
||||
|
||||
// Clamp the scalar as per Salvium's mx25519:
|
||||
// - Do NOT clear bits 0-2 (unlike standard X25519)
|
||||
// - Clear bit 255
|
||||
// - Do NOT set bit 254 (unlike standard X25519)
|
||||
const k = new Uint8Array(scalar);
|
||||
// k[0] &= 248; // Salvium does NOT clear bits 0-2
|
||||
k[31] &= 127; // Only clear bit 255
|
||||
|
||||
// Convert inputs to BigInt
|
||||
let kVal = 0n;
|
||||
let uVal = 0n;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
kVal |= BigInt(k[i]) << (8n * BigInt(i));
|
||||
uVal |= BigInt(u[i]) << (8n * BigInt(i));
|
||||
}
|
||||
uVal &= (1n << 255n) - 1n; // Clear top bit
|
||||
|
||||
// Montgomery ladder
|
||||
let x1 = uVal;
|
||||
let x2 = 1n;
|
||||
let z2 = 0n;
|
||||
let x3 = uVal;
|
||||
let z3 = 1n;
|
||||
let swap = 0n;
|
||||
|
||||
// Process bits from 254 down to 0
|
||||
for (let t = 254; t >= 0; t--) {
|
||||
const kt = (kVal >> BigInt(t)) & 1n;
|
||||
swap ^= kt;
|
||||
|
||||
// Conditional swap
|
||||
if (swap) {
|
||||
[x2, x3] = [x3, x2];
|
||||
[z2, z3] = [z3, z2];
|
||||
}
|
||||
swap = kt;
|
||||
|
||||
// Montgomery ladder step (matching Salvium's mx25519 portable implementation)
|
||||
const D = (p + x3 - z3) % p; // tmp0 = x3 - z3
|
||||
const B = (p + x2 - z2) % p; // tmp1 = x2 - z2
|
||||
const A = (x2 + z2) % p; // x2 = x2 + z2 (reusing as A)
|
||||
const C = (x3 + z3) % p; // z2 = x3 + z3 (reusing as C)
|
||||
const DA = (D * A) % p; // z3 = D * A
|
||||
const CB = (C * B) % p; // z2 = C * B
|
||||
const BB = (B * B) % p; // tmp0 = B^2
|
||||
const AA = (A * A) % p; // tmp1 = A^2
|
||||
const x3_new = ((DA + CB) % p) ** 2n % p; // x3 = (DA + CB)^2
|
||||
const diff = (p + DA - CB) % p;
|
||||
const z2_diff = (diff * diff) % p; // z2 = (DA - CB)^2
|
||||
const x2_new = (AA * BB) % p; // x2 = AA * BB
|
||||
const E = (p + AA - BB) % p; // tmp1 = AA - BB = E
|
||||
const z3_new = (x1 * z2_diff) % p; // z3 = x1 * (DA - CB)^2
|
||||
const a24E = (a24 * E) % p; // z3 = a24 * E
|
||||
const z2_new = (E * ((BB + a24E) % p)) % p; // z2 = E * (BB + a24 * E)
|
||||
|
||||
x2 = x2_new;
|
||||
z2 = z2_new;
|
||||
x3 = x3_new;
|
||||
z3 = z3_new;
|
||||
}
|
||||
|
||||
// Final conditional swap
|
||||
if (swap) {
|
||||
[x2, x3] = [x3, x2];
|
||||
[z2, z3] = [z3, z2];
|
||||
}
|
||||
|
||||
// Compute result = x2 / z2
|
||||
const z2Inv = modPow(z2, p - 2n, p);
|
||||
const result = (x2 * z2Inv) % p;
|
||||
|
||||
// Convert to bytes
|
||||
const out = new Uint8Array(32);
|
||||
let val = result;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
out[i] = Number(val & 0xffn);
|
||||
val >>= 8n;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
// x25519ScalarMult is now 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 };
|
||||
|
||||
/**
|
||||
* Perform X25519 ECDH key exchange for CARROT scanning
|
||||
|
||||
+109
-1
@@ -13,7 +13,7 @@
|
||||
* @module crypto/backend-ffi
|
||||
*/
|
||||
|
||||
import { dlopen, FFIType } from 'bun:ffi';
|
||||
import { dlopen, FFIType, CString } from 'bun:ffi';
|
||||
import { sha256 as nobleSha256 } from '@noble/hashes/sha2.js';
|
||||
|
||||
const { ptr, i32, u32, usize } = FFIType;
|
||||
@@ -157,6 +157,13 @@ const FFI_SYMBOLS = {
|
||||
// AES-256-GCM
|
||||
salvium_aes256gcm_encrypt: { args: [ptr, ptr, usize, ptr, ptr], returns: i32 },
|
||||
salvium_aes256gcm_decrypt: { args: [ptr, 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 },
|
||||
|
||||
// Buffer management
|
||||
salvium_storage_free_buf: { args: [ptr, usize], returns: FFIType.void },
|
||||
};
|
||||
|
||||
// ─── Backend class ──────────────────────────────────────────────────────────
|
||||
@@ -611,4 +618,105 @@ export class FfiCryptoBackend {
|
||||
const actualLen = Number(outLenBuf.readBigUInt64LE(0));
|
||||
return new Uint8Array(out.slice(0, actualLen));
|
||||
}
|
||||
|
||||
// ─── CARROT Output Scanning ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Scan a CARROT output using the native Rust pipeline (single FFI call).
|
||||
* Returns scan result object or null if not owned.
|
||||
*/
|
||||
scanCarrotOutput(ko, viewTag, dE, encAmount, commitment, kVi, accountSpendPubkey, inputContext, subaddressMap, clearTextAmount) {
|
||||
return this._carrotScan(
|
||||
'salvium_carrot_scan_output',
|
||||
ko, viewTag, dE, encAmount, commitment,
|
||||
kVi, accountSpendPubkey, inputContext,
|
||||
subaddressMap, clearTextAmount
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a CARROT output using the self-send (internal) path.
|
||||
* viewBalanceSecret is used directly as s_sr_unctx (no X25519 ECDH).
|
||||
*/
|
||||
scanCarrotInternalOutput(ko, viewTag, dE, encAmount, commitment, viewBalanceSecret, accountSpendPubkey, inputContext, subaddressMap, clearTextAmount) {
|
||||
return this._carrotScan(
|
||||
'salvium_carrot_scan_internal',
|
||||
ko, viewTag, dE, encAmount, commitment,
|
||||
viewBalanceSecret, accountSpendPubkey, inputContext,
|
||||
subaddressMap, clearTextAmount
|
||||
);
|
||||
}
|
||||
|
||||
/** @private Shared implementation for both scan paths. */
|
||||
_carrotScan(symbolName, ko, viewTag, dE, encAmount, commitment, secretKey, accountSpendPubkey, inputContext, subaddressMap, clearTextAmount) {
|
||||
const bKo = ensureBuffer(ko);
|
||||
const bVt = ensureBuffer(viewTag);
|
||||
const bDe = ensureBuffer(dE);
|
||||
const bEnc = ensureBuffer(encAmount || new Uint8Array(8));
|
||||
const bCommit = commitment ? ensureBuffer(commitment) : null;
|
||||
const bSk = ensureBuffer(secretKey);
|
||||
const bKs = ensureBuffer(accountSpendPubkey);
|
||||
const bIc = ensureBuffer(inputContext);
|
||||
|
||||
// Encode clear text amount: u64::MAX (0xFFFFFFFFFFFFFFFF) means "not provided"
|
||||
const ctAmount = (clearTextAmount !== undefined && clearTextAmount !== null)
|
||||
? 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);
|
||||
}
|
||||
|
||||
// Rust-allocated output pointers
|
||||
const outPtrBuf = Buffer.alloc(8); // *mut u8
|
||||
const outLenBuf = Buffer.alloc(8); // usize
|
||||
|
||||
const rc = this.lib.symbols[symbolName](
|
||||
bKo, bVt, bDe, bEnc,
|
||||
bCommit, // nullable
|
||||
bSk, bKs, bIc, bIc.length,
|
||||
ctAmount,
|
||||
subBuf, nSub,
|
||||
outPtrBuf, outLenBuf
|
||||
);
|
||||
|
||||
if (rc === 0) return null; // Not owned
|
||||
if (rc < 0) throw new Error(`${symbolName} failed`);
|
||||
|
||||
// Read JSON from Rust-allocated buffer using CString (safe before free)
|
||||
const resultPtr = Number(outPtrBuf.readBigUInt64LE(0));
|
||||
const resultLen = Number(outLenBuf.readBigUInt64LE(0));
|
||||
const jsonStr = new CString(resultPtr, 0, resultLen).toString();
|
||||
|
||||
// Free Rust-allocated buffer
|
||||
this.lib.symbols.salvium_storage_free_buf(resultPtr, resultLen);
|
||||
|
||||
const result = JSON.parse(jsonStr);
|
||||
|
||||
return {
|
||||
owned: true,
|
||||
onetimeAddress: bytesToHex(ko),
|
||||
addressSpendPubkey: result.address_spend_pubkey,
|
||||
sharedSecret: result.shared_secret,
|
||||
amount: BigInt(result.amount),
|
||||
mask: hexToBytes(result.mask),
|
||||
enoteType: result.enote_type,
|
||||
subaddressIndex: { major: result.subaddress_major, minor: result.subaddress_minor },
|
||||
isMainAddress: result.is_main_address,
|
||||
isCarrot: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ export class JsCryptoBackend {
|
||||
scCheck(s) { return scCheck(s); }
|
||||
scIsZero(s) { return scIsZero(s); }
|
||||
|
||||
// X25519 (pure JS fallback — Montgomery ladder with BigInt)
|
||||
x25519ScalarMult(scalar, uCoord) {
|
||||
return jsX25519ScalarMult(scalar, uCoord);
|
||||
}
|
||||
|
||||
// Point ops
|
||||
scalarMultBase(s) { return scalarMultBase(s); }
|
||||
scalarMultPoint(s, p) { return scalarMultPoint(s, p); }
|
||||
@@ -197,6 +202,71 @@ function derSignatureToRaw(der, componentLen) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure JS X25519 scalar multiplication (Montgomery ladder with BigInt).
|
||||
* Implements Salvium's mx25519 variant: only clears bit 255, does NOT
|
||||
* clear bits 0-2 or set bit 254 (unlike RFC 7748).
|
||||
*/
|
||||
function jsX25519ScalarMult(scalar, u) {
|
||||
const p = 2n ** 255n - 19n;
|
||||
const a24 = 121666n;
|
||||
|
||||
const k = new Uint8Array(scalar);
|
||||
k[31] &= 127; // Only clear bit 255
|
||||
|
||||
let kVal = 0n;
|
||||
let uVal = 0n;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
kVal |= BigInt(k[i]) << (8n * BigInt(i));
|
||||
uVal |= BigInt(u[i]) << (8n * BigInt(i));
|
||||
}
|
||||
uVal &= (1n << 255n) - 1n;
|
||||
|
||||
let x1 = uVal;
|
||||
let x2 = 1n, z2 = 0n, x3 = uVal, z3 = 1n;
|
||||
let swap = 0n;
|
||||
|
||||
for (let t = 254; t >= 0; t--) {
|
||||
const kt = (kVal >> BigInt(t)) & 1n;
|
||||
swap ^= kt;
|
||||
if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; }
|
||||
swap = kt;
|
||||
|
||||
const D = (p + x3 - z3) % p;
|
||||
const B = (p + x2 - z2) % p;
|
||||
const A = (x2 + z2) % p;
|
||||
const C = (x3 + z3) % p;
|
||||
const DA = (D * A) % p;
|
||||
const CB = (C * B) % p;
|
||||
const BB = (B * B) % p;
|
||||
const AA = (A * A) % p;
|
||||
x3 = ((DA + CB) % p) ** 2n % p;
|
||||
const diff = (p + DA - CB) % p;
|
||||
const z2_diff = (diff * diff) % p;
|
||||
x2 = (AA * BB) % p;
|
||||
const E = (p + AA - BB) % p;
|
||||
z3 = (x1 * z2_diff) % p;
|
||||
const a24E = (a24 * E) % p;
|
||||
z2 = (E * ((BB + a24E) % p)) % p;
|
||||
}
|
||||
|
||||
if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; }
|
||||
|
||||
// modPow for inversion
|
||||
let base = z2 % p, exp = p - 2n, result = 1n;
|
||||
while (exp > 0n) {
|
||||
if (exp % 2n === 1n) result = (result * base) % p;
|
||||
exp >>= 1n;
|
||||
base = (base * base) % p;
|
||||
}
|
||||
const finalResult = (x2 * result) % p;
|
||||
|
||||
const out = new Uint8Array(32);
|
||||
let val = finalResult;
|
||||
for (let i = 0; i < 32; i++) { out[i] = Number(val & 0xffn); val >>= 8n; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Check if haystack contains needle bytes at any offset */
|
||||
function containsBytes(haystack, needle) {
|
||||
outer: for (let i = 0; i <= haystack.length - needle.length; i++) {
|
||||
|
||||
@@ -108,6 +108,9 @@ export class WasmCryptoBackend {
|
||||
pointNegate(p) { return nullIfEmpty(this.wasm.point_negate(p)); }
|
||||
doubleScalarMultBase(a, p, b) { return nullIfEmpty(this.wasm.double_scalar_mult_base(a, p, b)); }
|
||||
|
||||
// X25519
|
||||
x25519ScalarMult(scalar, uCoord) { return this.wasm.x25519_scalar_mult(scalar, uCoord); }
|
||||
|
||||
// Hash-to-point & key derivation
|
||||
hashToPoint(data) { return this.wasm.hash_to_point(data); }
|
||||
generateKeyImage(pubKey, secKey) {
|
||||
|
||||
@@ -41,6 +41,8 @@ export {
|
||||
sha256, verifySignature,
|
||||
// Key derivation
|
||||
argon2id,
|
||||
// X25519
|
||||
x25519ScalarMult,
|
||||
// CARROT key derivation
|
||||
computeCarrotSpendPubkey, computeCarrotAccountViewPubkey,
|
||||
computeCarrotMainAddressViewPubkey,
|
||||
|
||||
@@ -287,5 +287,10 @@ export function computeCarrotMainAddressViewPubkey(k_vi) {
|
||||
return scalarMultBase(k_vi);
|
||||
}
|
||||
|
||||
// X25519
|
||||
export function x25519ScalarMult(scalar, uCoord) {
|
||||
return getCryptoBackend().x25519ScalarMult(scalar, uCoord);
|
||||
}
|
||||
|
||||
// Scalar add (simple wrapper around scAdd)
|
||||
export function scalarAdd(a, b) { return scAdd(a, b); }
|
||||
|
||||
+65
-19
@@ -17,6 +17,7 @@ import { parseTransaction, parseBlock, extractTxPubKey, extractPaymentId, extrac
|
||||
import { bytesToHex, hexToBytes } from './address.js';
|
||||
import { TX_TYPE } from './wallet.js';
|
||||
import {
|
||||
getCryptoBackend,
|
||||
generateKeyDerivation, derivePublicKey, deriveSecretKey,
|
||||
generateKeyImage, commit as pedersonCommit,
|
||||
deriveViewTag, computeSharedSecret, ecdhDecodeFull,
|
||||
@@ -1533,19 +1534,44 @@ export class WalletSync {
|
||||
: BigInt(output.amount || 0);
|
||||
}
|
||||
|
||||
// Scan with CARROT algorithm
|
||||
// Scan with CARROT algorithm — prefer native Rust scanner (single FFI call)
|
||||
// over JS scanner (many individual crypto ops).
|
||||
const backend = getCryptoBackend();
|
||||
const hasNativeScanner = typeof backend.scanCarrotOutput === 'function';
|
||||
let result;
|
||||
let isReturnOutput = false;
|
||||
try {
|
||||
result = scanCarrotOutput(
|
||||
outputForScan,
|
||||
this.carrotKeys.viewIncomingKey,
|
||||
this.carrotKeys.accountSpendPubkey,
|
||||
inputContext,
|
||||
this.carrotSubaddresses,
|
||||
amountCommitment,
|
||||
scanOptions
|
||||
);
|
||||
if (hasNativeScanner) {
|
||||
// Native Rust scanner: extract raw fields and pass directly
|
||||
const Ko = typeof outputForScan.key === 'string' ? hexToBytes(outputForScan.key) : outputForScan.key;
|
||||
const vtBytes = typeof outputForScan.viewTag === 'string' ? hexToBytes(outputForScan.viewTag) : outputForScan.viewTag;
|
||||
const De = typeof outputForScan.enoteEphemeralPubkey === 'string'
|
||||
? hexToBytes(outputForScan.enoteEphemeralPubkey) : outputForScan.enoteEphemeralPubkey;
|
||||
const encAmt = outputForScan.encryptedAmount
|
||||
? (typeof outputForScan.encryptedAmount === 'string'
|
||||
? hexToBytes(outputForScan.encryptedAmount) : outputForScan.encryptedAmount)
|
||||
: null;
|
||||
result = backend.scanCarrotOutput(
|
||||
Ko, vtBytes, De, encAmt,
|
||||
amountCommitment,
|
||||
this.carrotKeys.viewIncomingKey,
|
||||
this.carrotKeys.accountSpendPubkey,
|
||||
inputContext,
|
||||
this.carrotSubaddresses,
|
||||
scanOptions.clearTextAmount
|
||||
);
|
||||
} else {
|
||||
// JS fallback scanner
|
||||
result = scanCarrotOutput(
|
||||
outputForScan,
|
||||
this.carrotKeys.viewIncomingKey,
|
||||
this.carrotKeys.accountSpendPubkey,
|
||||
inputContext,
|
||||
this.carrotSubaddresses,
|
||||
amountCommitment,
|
||||
scanOptions
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Missing required fields in CARROT output - skip this output
|
||||
return null;
|
||||
@@ -1556,15 +1582,35 @@ export class WalletSync {
|
||||
// Only for regular transactions with key images (not coinbase/protocol_tx).
|
||||
if (!result && this.carrotKeys.viewBalanceSecret && firstKi) {
|
||||
try {
|
||||
result = scanCarrotInternalOutput(
|
||||
outputForScan,
|
||||
this.carrotKeys.viewBalanceSecret,
|
||||
this.carrotKeys.accountSpendPubkey,
|
||||
inputContext,
|
||||
this.carrotSubaddresses,
|
||||
amountCommitment,
|
||||
scanOptions
|
||||
);
|
||||
if (hasNativeScanner) {
|
||||
const Ko = typeof outputForScan.key === 'string' ? hexToBytes(outputForScan.key) : outputForScan.key;
|
||||
const vtBytes = typeof outputForScan.viewTag === 'string' ? hexToBytes(outputForScan.viewTag) : outputForScan.viewTag;
|
||||
const De = typeof outputForScan.enoteEphemeralPubkey === 'string'
|
||||
? hexToBytes(outputForScan.enoteEphemeralPubkey) : outputForScan.enoteEphemeralPubkey;
|
||||
const encAmt = outputForScan.encryptedAmount
|
||||
? (typeof outputForScan.encryptedAmount === 'string'
|
||||
? hexToBytes(outputForScan.encryptedAmount) : outputForScan.encryptedAmount)
|
||||
: null;
|
||||
result = backend.scanCarrotInternalOutput(
|
||||
Ko, vtBytes, De, encAmt,
|
||||
amountCommitment,
|
||||
this.carrotKeys.viewBalanceSecret,
|
||||
this.carrotKeys.accountSpendPubkey,
|
||||
inputContext,
|
||||
this.carrotSubaddresses,
|
||||
scanOptions.clearTextAmount
|
||||
);
|
||||
} else {
|
||||
result = scanCarrotInternalOutput(
|
||||
outputForScan,
|
||||
this.carrotKeys.viewBalanceSecret,
|
||||
this.carrotKeys.accountSpendPubkey,
|
||||
inputContext,
|
||||
this.carrotSubaddresses,
|
||||
amountCommitment,
|
||||
scanOptions
|
||||
);
|
||||
}
|
||||
|
||||
// If we detected a self-send, compute the expected return address.
|
||||
// When this wallet's STAKE tx unlocks, the return output will appear
|
||||
|
||||
Reference in New Issue
Block a user