Files
salvium-rs/crates/salvium-tx/tests/testnet.rs
T
Matt Hess a13c1d6451 Salvium 2 layers 2-5: validation, builder, CLI, FFI, RPC
- Fix builder version selection to emit v5 for CREATE_TOKEN/ROLLUP
  - Add consensus validation for tx types 9-10 (HF11 gating, asset rules)
  - Wire wallet token module (validate_create_token_params, CREATE_TOKEN_COST)
  - Add CreateToken CLI command with full build/sign/submit pipeline
  - Add salvium_wallet_create_token FFI endpoint with token metadata support
  - Add create_token wallet RPC types and method
  - Fix CLI mempool scan skipped when wallet already at chain tip
  - Show pool TXs in history by default, display "pool" instead of height 0
  - Normalize FFI input param structs to camelCase (#[serde(rename_all)])
  - Update tx_type_name for types 9 (CREATE_TOKEN) and 10 (ROLLUP)
  - Add integration testing docs (docs/integration-testing.md)
2026-03-03 15:50:54 +00:00

452 lines
17 KiB
Rust

//! Testnet integration tests for the transaction pipeline.
//!
//! Run with: cargo test -p salvium-tx --test testnet -- --ignored
//!
//! These tests verify the transaction construction pipeline components
//! against real blockchain data from the testnet daemon.
use salvium_rpc::DaemonRpc;
use salvium_tx::decoy::{DecoySelector, DEFAULT_RING_SIZE};
use salvium_tx::types::*;
fn daemon() -> DaemonRpc {
let url = std::env::var("TESTNET_DAEMON_URL")
.unwrap_or_else(|_| "http://node12.whiskymine.io:29081".to_string());
DaemonRpc::new(&url)
}
fn hex_to_32(s: &str) -> [u8; 32] {
let bytes = hex::decode(s).expect("invalid hex");
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes[..32]);
arr
}
// ─── 1. Decoy Selection with Real Distribution ──────────────────────────────
#[tokio::test]
#[ignore]
async fn test_decoy_selector_real_distribution() {
let d = daemon();
let dist = d
.get_output_distribution(&[0], 0, 0, true, "")
.await
.expect("get_output_distribution failed");
let rct_offsets = dist[0].distribution.clone();
println!(
"Distribution: {} blocks, {} total outputs",
rct_offsets.len(),
rct_offsets.last().unwrap_or(&0)
);
let selector = DecoySelector::new(rct_offsets.clone())
.expect("DecoySelector::new should succeed with real data");
// Build a ring for a mid-chain output.
let total_outputs = *rct_offsets.last().unwrap();
let real_index = total_outputs / 2;
let (ring, real_pos) =
selector.build_ring(real_index, DEFAULT_RING_SIZE).expect("build_ring should succeed");
assert_eq!(ring.len(), DEFAULT_RING_SIZE);
assert_eq!(ring[real_pos], real_index, "real output should be at real_pos");
// Ring should be sorted ascending.
for i in 1..ring.len() {
assert!(ring[i] > ring[i - 1], "ring should be sorted ascending");
}
// All indices should be in range.
for &idx in &ring {
assert!(idx < total_outputs, "ring member {} out of range (max {})", idx, total_outputs);
}
println!("Ring built: real_index={}, real_pos={}, ring={:?}", real_index, real_pos, &ring);
}
#[tokio::test]
#[ignore]
async fn test_decoy_selector_multiple_rings() {
let d = daemon();
let dist = d
.get_output_distribution(&[0], 0, 0, true, "")
.await
.expect("get_output_distribution failed");
let rct_offsets = dist[0].distribution.clone();
let selector = DecoySelector::new(rct_offsets.clone()).unwrap();
let total_outputs = *rct_offsets.last().unwrap();
// Build 10 rings for different positions and verify uniqueness.
for i in 0..10u64 {
let idx = (total_outputs * (i + 1)) / 12;
let (ring, _) = selector.build_ring(idx, DEFAULT_RING_SIZE).unwrap();
assert_eq!(ring.len(), DEFAULT_RING_SIZE);
// Check no duplicates.
let mut sorted = ring.clone();
sorted.dedup();
assert_eq!(sorted.len(), ring.len(), "ring {} should have no duplicates", i);
}
println!("Built 10 unique rings successfully");
}
// ─── 2. Fetch Ring Member Data ──────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_fetch_ring_members() {
let d = daemon();
let dist = d
.get_output_distribution(&[0], 0, 0, true, "")
.await
.expect("get_output_distribution failed");
let rct_offsets = dist[0].distribution.clone();
let selector = DecoySelector::new(rct_offsets.clone()).unwrap();
let total_outputs = *rct_offsets.last().unwrap();
// Build a ring.
let real_index = total_outputs / 3;
let (ring_indices, real_pos) = selector.build_ring(real_index, DEFAULT_RING_SIZE).unwrap();
// Fetch all ring members from daemon.
let requests: Vec<salvium_rpc::daemon::OutputRequest> = ring_indices
.iter()
.map(|&idx| salvium_rpc::daemon::OutputRequest { amount: 0, index: idx })
.collect();
let outs = d.get_outs(&requests, false, "").await.expect("get_outs failed");
assert_eq!(outs.len(), DEFAULT_RING_SIZE);
// Parse all keys and commitments.
let mut ring_keys = Vec::new();
let mut ring_commitments = Vec::new();
for out in &outs {
let key = hex_to_32(&out.key);
let mask = hex_to_32(&out.mask);
ring_keys.push(key);
ring_commitments.push(mask);
assert!(out.unlocked || out.height == 0, "ring member should be unlocked or genesis");
}
println!(
"Ring members fetched: {} keys, {} commitments",
ring_keys.len(),
ring_commitments.len()
);
println!("Real output at ring position {}: key={}", real_pos, &outs[real_pos].key[..16]);
}
// ─── 3. Coinbase TX Parsing ─────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_parse_coinbase_from_chain() {
let d = daemon();
let header = d.get_block_header_by_height(1250).await.unwrap();
// Fetch the block to get the miner TX hash.
let miner_tx_hash = header.miner_tx_hash.expect("block should have miner_tx_hash");
println!("Miner TX hash: {}", miner_tx_hash);
println!("Reward: {} ({:.9} SAL)", header.reward, header.reward as f64 / 1e9);
// Note: coinbase TXs may not be directly fetchable via get_transactions
// on all daemons. The reward and hash confirm the block is valid.
assert!(!miner_tx_hash.is_empty());
assert_eq!(header.height, 1250);
assert!(header.reward > 0);
}
// ─── 4. Address Generation Round-Trip ────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_address_generation_testnet() {
use salvium_types::address::{create_address_raw, is_valid_address, parse_address};
use salvium_types::constants::{AddressFormat, AddressType, Network};
// Generate a random keypair.
let seed = [42u8; 32];
let spend_key = salvium_crypto::keccak256(&seed);
let mut spend_secret = [0u8; 32];
spend_secret.copy_from_slice(&spend_key[..32]);
let spend_public = salvium_crypto::scalar_mult_base(&spend_secret);
let mut spend_pub = [0u8; 32];
spend_pub.copy_from_slice(&spend_public[..32]);
let view_key = salvium_crypto::keccak256(&spend_secret);
let mut view_secret = [0u8; 32];
view_secret.copy_from_slice(&view_key[..32]);
let view_public = salvium_crypto::scalar_mult_base(&view_secret);
let mut view_pub = [0u8; 32];
view_pub.copy_from_slice(&view_public[..32]);
// Test all address types for testnet.
let test_cases = [
(AddressFormat::Legacy, AddressType::Standard, "Legacy standard"),
(AddressFormat::Legacy, AddressType::Subaddress, "Legacy subaddress"),
(AddressFormat::Carrot, AddressType::Standard, "CARROT standard"),
(AddressFormat::Carrot, AddressType::Subaddress, "CARROT subaddress"),
];
for (format, addr_type, label) in &test_cases {
let addr =
create_address_raw(Network::Testnet, *format, *addr_type, &spend_pub, &view_pub, None)
.unwrap_or_else(|_| panic!("{} address creation failed", label));
assert!(is_valid_address(&addr), "{} address should be valid: {}", label, &addr[..20]);
let parsed =
parse_address(&addr).unwrap_or_else(|_| panic!("{} address parsing failed", label));
assert_eq!(parsed.network, Network::Testnet);
assert_eq!(parsed.format, *format);
assert_eq!(parsed.address_type, *addr_type);
assert_eq!(parsed.spend_public_key, spend_pub);
assert_eq!(parsed.view_public_key, view_pub);
println!("{}: {}...", label, &addr[..24]);
}
// Test integrated address (with payment ID).
let payment_id = [0x42u8; 8];
let addr = create_address_raw(
Network::Testnet,
AddressFormat::Legacy,
AddressType::Integrated,
&spend_pub,
&view_pub,
Some(&payment_id),
)
.expect("integrated address creation failed");
let parsed = parse_address(&addr).expect("integrated address parse failed");
assert_eq!(parsed.address_type, AddressType::Integrated);
assert_eq!(parsed.payment_id, Some(payment_id));
println!("Legacy integrated: {}...", &addr[..24]);
}
// ─── 5. Fee Estimation ──────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_fee_estimation_realistic() {
use salvium_tx::fee::estimate_tx_fee;
use salvium_types::consensus::FEE_PER_BYTE;
// Standard transfer: 2 inputs, 2 outputs, ring size 16, TCLSAG, CARROT.
// fee_per_byte already includes priority tier (from daemon's fees[] array).
let fee = estimate_tx_fee(2, 2, 16, true, 0x04, FEE_PER_BYTE);
assert!(fee > 0, "fee should be positive");
let fee_sal = fee as f64 / 1e9;
println!("Standard 2-in/2-out TCLSAG fee: {} atomic ({:.9} SAL)", fee, fee_sal);
assert!(fee_sal < 1.0, "fee should be less than 1 SAL for a normal TX");
}
// ─── 6. Full Build + Sign (synthetic, no daemon submission) ──────────────────
#[tokio::test]
#[ignore]
async fn test_build_and_sign_with_real_decoys() {
let d = daemon();
// 1. Get distribution.
let dist = d
.get_output_distribution(&[0], 0, 0, true, "")
.await
.expect("get_output_distribution failed");
let rct_offsets = dist[0].distribution.clone();
let selector = DecoySelector::new(rct_offsets.clone()).unwrap();
// 2. Create a synthetic input with a real keypair.
let amount = 2_000_000_000u64;
let mask = random_scalar();
let commitment = to_32(&salvium_crypto::pedersen_commit(&amount.to_le_bytes(), &mask));
let (sk_x, sk_y, pk) = test_keypair_tclsag();
// 3. Build ring with real decoys.
// Use a low index so we know the output exists.
let real_index = 5u64;
let (ring_indices, real_pos) = selector.build_ring(real_index, DEFAULT_RING_SIZE).unwrap();
// 4. Fetch real ring member keys and commitments.
let requests: Vec<salvium_rpc::daemon::OutputRequest> = ring_indices
.iter()
.map(|&idx| salvium_rpc::daemon::OutputRequest { amount: 0, index: idx })
.collect();
let outs = d.get_outs(&requests, false, "").await.unwrap();
let mut ring_keys: Vec<[u8; 32]> = outs.iter().map(|o| hex_to_32(&o.key)).collect();
let mut ring_commitments: Vec<[u8; 32]> = outs.iter().map(|o| hex_to_32(&o.mask)).collect();
// Override the real position with our synthetic key and commitment.
ring_keys[real_pos] = pk;
ring_commitments[real_pos] = commitment;
let input = salvium_tx::builder::PreparedInput {
secret_key: sk_x,
secret_key_y: Some(sk_y),
public_key: pk,
amount,
mask,
asset_type: "SAL".to_string(),
global_index: real_index,
ring: ring_keys,
ring_commitments,
ring_indices: ring_indices.clone(),
real_index: real_pos,
};
// 5. Build unsigned transaction.
let send_amount = 1_000_000_000u64;
let fee = salvium_tx::estimate_tx_fee(
1,
2,
DEFAULT_RING_SIZE,
true,
0x04,
salvium_types::consensus::FEE_PER_BYTE,
);
let change = amount - send_amount - fee;
let output_mask1 = random_scalar();
let output_mask2 = random_scalar();
let out_commit1 =
to_32(&salvium_crypto::pedersen_commit(&send_amount.to_le_bytes(), &output_mask1));
let out_commit2 = to_32(&salvium_crypto::pedersen_commit(&change.to_le_bytes(), &output_mask2));
let ki = to_32(&salvium_crypto::generate_key_image(&pk, &sk_x));
let unsigned = salvium_tx::builder::UnsignedTransaction {
prefix: TxPrefix {
version: 2,
unlock_time: 0,
inputs: vec![TxInput::Key {
amount: 0,
asset_type: "SAL".to_string(),
key_offsets: relative_offsets(&ring_indices),
key_image: ki,
}],
outputs: vec![
TxOutput::CarrotV1 {
amount: 0,
key: [0xAA; 32],
asset_type: "SAL".to_string(),
view_tag: [1, 2, 3],
encrypted_janus_anchor: vec![0u8; 16],
},
TxOutput::CarrotV1 {
amount: 0,
key: [0xBB; 32],
asset_type: "SAL".to_string(),
view_tag: [4, 5, 6],
encrypted_janus_anchor: vec![0u8; 16],
},
],
extra: vec![],
tx_type: tx_type::TRANSFER,
amount_burnt: 0,
return_address: None,
return_pubkey: None,
return_address_list: None,
return_address_change_mask: None,
protocol_tx_data: None,
source_asset_type: "SAL".to_string(),
destination_asset_type: "SAL".to_string(),
amount_slippage_limit: 0,
rollup_binding_tag: None,
token_metadata: None,
layer2_rollup_data: None,
},
output_masks: vec![output_mask1, output_mask2],
output_amounts: vec![send_amount, change],
encrypted_amounts: vec![[0u8; 8], [0u8; 8]],
output_commitments: vec![out_commit1, out_commit2],
inputs: vec![input],
rct_type: rct_type::SALVIUM_ONE,
fee,
ephemeral_keys: vec![],
};
// 6. Sign!
let signed = salvium_tx::sign_transaction(unsigned)
.expect("sign_transaction should succeed with real decoys");
let rct = signed.rct.as_ref().unwrap();
assert_eq!(rct.tclsags.len(), 1, "should have 1 TCLSAG signature");
assert_eq!(rct.bulletproof_plus.len(), 1, "should have 1 BP+ proof");
assert_eq!(rct.pseudo_outs.len(), 1);
// 7. Verify TCLSAG signature.
let _prefix_hash = signed.prefix_hash().expect("prefix_hash");
// We can't easily re-derive the full message here without the internal
// serialization helpers, but the signature was produced without panicking,
// which validates the pipeline.
println!("Transaction built and signed successfully with real decoy ring!");
println!(" Inputs: {}", signed.input_count());
println!(" Outputs: {}", signed.output_count());
println!(" Fee: {} ({:.9} SAL)", fee, fee as f64 / 1e9);
println!(" TCLSAG sigs: {}", rct.tclsags.len());
println!(" BP+ proofs: {}", rct.bulletproof_plus.len());
// 8. Serialize to bytes to verify serialization works.
match signed.to_bytes() {
Ok(bytes) => {
println!(" Serialized size: {} bytes", bytes.len());
println!(" TX hex (first 64): {}", &hex::encode(&bytes[..32.min(bytes.len())]));
}
Err(e) => {
// Serialization may fail because our output keys aren't real CARROT outputs.
// That's OK for this test — we're testing the signing pipeline, not output construction.
println!(" Serialization note: {} (expected with synthetic outputs)", e);
}
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
fn random_scalar() -> [u8; 32] {
use rand::RngCore;
let mut buf = [0u8; 64];
rand::thread_rng().fill_bytes(&mut buf);
to_32(&salvium_crypto::sc_reduce64(&buf))
}
fn to_32(v: &[u8]) -> [u8; 32] {
let mut arr = [0u8; 32];
let len = v.len().min(32);
arr[..len].copy_from_slice(&v[..len]);
arr
}
/// The TCLSAG T generator constant.
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,
];
fn test_keypair_tclsag() -> ([u8; 32], [u8; 32], [u8; 32]) {
let sk_x = random_scalar();
let sk_y = random_scalar();
let g_part = salvium_crypto::scalar_mult_base(&sk_x);
let t_part = salvium_crypto::scalar_mult_point(&sk_y, &T_BYTES);
let pk = to_32(&salvium_crypto::point_add_compressed(&g_part, &t_part));
(sk_x, sk_y, pk)
}
fn relative_offsets(indices: &[u64]) -> Vec<u64> {
if indices.is_empty() {
return Vec::new();
}
let mut result = Vec::with_capacity(indices.len());
result.push(indices[0]);
for i in 1..indices.len() {
result.push(indices[i] - indices[i - 1]);
}
result
}