a13c1d6451
- 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)
452 lines
17 KiB
Rust
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
|
|
}
|