Files
salvium-rs/crates/salvium-wallet/tests/testnet_stake.rs
T
Matt Hess 65d596a6ea Add sync cancellation via AtomicBool flag for FFI consumers
Allows Android/FFI callers to interrupt an in-progress wallet sync
  cleanly between batches. Adds salvium_wallet_stop_sync FFI export,
  WalletError::Cancelled, SyncEvent::Cancelled, and WalletHandle
  wrapper that pairs each wallet with its cancellation flag.
2026-02-26 02:23:12 +00:00

505 lines
18 KiB
Rust

//! Testnet integration: STAKE transaction construction and submission.
//!
//! Tests building STAKE transactions using the TransactionBuilder, verifying
//! the correct structure (tx_type=STAKE, unlock_time set, amount_burnt=0),
//! and optionally submitting to the testnet daemon.
//!
//! Run with: cargo test -p salvium-wallet --test testnet_stake -- --ignored --nocapture
//!
//! Ported from: test/stake-integration.test.js + test/stake-transaction.test.js
use salvium_rpc::daemon::{DaemonRpc, OutputRequest};
use salvium_tx::builder::{Destination, PreparedInput, TransactionBuilder};
use salvium_tx::decoy::{DecoySelector, DEFAULT_RING_SIZE};
use salvium_tx::fee::{self, FeePriority};
use salvium_tx::sign::sign_transaction;
use salvium_tx::types::*;
use salvium_types::constants::Network;
use salvium_wallet::{decrypt_js_wallet, Wallet};
use std::path::PathBuf;
const DAEMON_URL: &str = "http://node12.whiskymine.io:29081";
const STAKE_AMOUNT: u64 = 1_000_000_000; // 1 SAL
fn daemon() -> DaemonRpc {
let url = std::env::var("TESTNET_DAEMON_URL").unwrap_or_else(|_| DAEMON_URL.to_string());
DaemonRpc::new(&url)
}
fn testnet_wallet_dir() -> PathBuf {
dirs::home_dir().unwrap().join("testnet-wallet")
}
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
}
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
}
// =============================================================================
// Test 1: STAKE transaction structure (build only, no daemon)
// =============================================================================
#[tokio::test]
#[ignore]
async fn test_stake_transaction_build() {
println!("\n=== STAKE Transaction Build Test ===\n");
// Load wallet
let dir = testnet_wallet_dir();
let wallet_json = std::fs::read_to_string(dir.join("wallet-a.json"))
.expect("wallet-a.json not found in ~/testnet-wallet/");
let pin = std::fs::read_to_string(dir.join("wallet-a.pin"))
.expect("wallet-a.pin not found")
.trim()
.to_string();
let secrets = decrypt_js_wallet(&wallet_json, &pin).expect("failed to decrypt wallet");
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let db_path = temp_dir.path().join("wallet-a.db");
let mut wallet = Wallet::create(
secrets.seed,
Network::Testnet,
db_path.to_str().unwrap(),
&[0u8; 32],
)
.expect("failed to create wallet");
// Sync
let d = daemon();
let sync_height = wallet
.sync(&d, None, &std::sync::atomic::AtomicBool::new(false))
.await
.expect("sync failed");
println!("Synced to height {}", sync_height);
// Get hardfork info for asset type
let hf_info = d.hard_fork_info().await.expect("hard_fork_info failed");
let tx_asset_type = if hf_info.version >= 6 { "SAL1" } else { "SAL" };
let db_asset_type = "SAL";
// Check balance
let balance = wallet
.get_balance(db_asset_type, 0)
.expect("get_balance failed");
let unlocked: u64 = balance.unlocked_balance.parse().expect("invalid balance");
println!("Unlocked balance: {:.9} SAL", unlocked as f64 / 1e9);
assert!(unlocked > STAKE_AMOUNT, "insufficient balance for stake");
// Get current height for unlock_time calculation
let info = d.get_info().await.expect("get_info failed");
let current_height = info.height;
// Testnet stake lock period is 20 blocks
let stake_lock_period = 20u64;
let unlock_time = current_height + stake_lock_period;
// Select outputs
let estimated_fee = fee::estimate_tx_fee(
1,
2,
DEFAULT_RING_SIZE,
true,
output_type::CARROT_V1,
FeePriority::Normal,
);
let selection = wallet
.select_carrot_outputs(
STAKE_AMOUNT,
estimated_fee,
db_asset_type,
salvium_wallet::utxo::SelectionStrategy::Default,
)
.expect("output selection failed");
println!("Selected {} output(s)", selection.selected.len());
let keys = wallet.keys();
// Build the transaction with STAKE type
let builder = TransactionBuilder::new()
.set_tx_type(tx_type::STAKE)
.set_unlock_time(unlock_time)
.set_asset_types(tx_asset_type, tx_asset_type)
.set_rct_type(rct_type::SALVIUM_ONE)
.set_fee(estimated_fee);
// For STAKE, the destination is the sender's own address (self-spend to protocol).
let _builder = builder
.add_destination(Destination {
spend_pubkey: keys.carrot.account_spend_pubkey,
view_pubkey: keys.carrot.account_view_pubkey,
amount: STAKE_AMOUNT,
asset_type: tx_asset_type.to_string(),
payment_id: [0u8; 8],
is_subaddress: false,
})
.set_change_address(
keys.carrot.account_spend_pubkey,
keys.carrot.account_view_pubkey,
)
.set_change_view_balance_secret(keys.carrot.view_balance_secret);
// Add prepared inputs (simplified — we're testing structure, not full signing)
// For a full test, we'd need to prepare all inputs with ring members.
// Here we verify the builder accepts STAKE params.
println!("STAKE TX config:");
println!(" tx_type: STAKE ({})", tx_type::STAKE);
println!(
" unlock_time: {} (height {} + {} lock period)",
unlock_time, current_height, stake_lock_period
);
println!(" stake_amount: {:.9} SAL", STAKE_AMOUNT as f64 / 1e9);
println!(" fee: {:.9} SAL", estimated_fee as f64 / 1e9);
println!(" asset_type: {}", tx_asset_type);
println!("\nSTAKE transaction structure verified.");
}
// =============================================================================
// Test 2: STAKE submit to testnet
// =============================================================================
#[tokio::test]
#[ignore]
async fn test_stake_submit_testnet() {
println!("\n=== STAKE Transaction Submit Test ===\n");
let dir = testnet_wallet_dir();
let wallet_json =
std::fs::read_to_string(dir.join("wallet-a.json")).expect("wallet-a.json not found");
let pin = std::fs::read_to_string(dir.join("wallet-a.pin"))
.expect("wallet-a.pin not found")
.trim()
.to_string();
let secrets = decrypt_js_wallet(&wallet_json, &pin).expect("failed to decrypt");
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("wallet-a.db");
let mut wallet = Wallet::create(
secrets.seed,
Network::Testnet,
db_path.to_str().unwrap(),
&[0u8; 32],
)
.expect("create wallet");
let d = daemon();
let sync_height = wallet
.sync(&d, None, &std::sync::atomic::AtomicBool::new(false))
.await
.expect("sync failed");
println!("Synced to height {}", sync_height);
let hf_info = d.hard_fork_info().await.unwrap();
let tx_asset_type = if hf_info.version >= 6 { "SAL1" } else { "SAL" };
let db_asset_type = "SAL";
let balance = wallet.get_balance(db_asset_type, 0).unwrap();
let unlocked: u64 = balance.unlocked_balance.parse().unwrap();
if unlocked <= STAKE_AMOUNT {
println!(
"Insufficient balance ({} < {}), skipping submit test",
unlocked, STAKE_AMOUNT
);
return;
}
let info = d.get_info().await.unwrap();
let current_height = info.height;
let unlock_time = current_height + 20; // testnet lock period
// Fee
let fee_estimate = d.get_fee_estimate(10).await.unwrap();
let est_weight = fee::estimate_tx_weight(1, 2, DEFAULT_RING_SIZE, true, output_type::CARROT_V1);
let estimated_fee = (est_weight as u64) * fee_estimate.fee * FeePriority::Normal.multiplier();
// Select outputs
let selection = wallet
.select_carrot_outputs(
STAKE_AMOUNT,
estimated_fee,
db_asset_type,
salvium_wallet::utxo::SelectionStrategy::Default,
)
.expect("output selection failed");
// Get distribution for decoy selection
let dist = d
.get_output_distribution(&[0], 0, 0, true, tx_asset_type)
.await
.unwrap();
let decoy_selector = DecoySelector::new(dist[0].distribution.clone()).unwrap();
let keys = wallet.keys();
// Resolve asset-type indices and build inputs (same pattern as testnet_transfer.rs)
let tx_hashes_to_resolve: Vec<String> = selection
.selected
.iter()
.map(|u| {
wallet
.get_output(&u.key_image)
.unwrap()
.unwrap()
.tx_hash
.clone()
})
.collect();
let tx_hash_refs: Vec<&str> = tx_hashes_to_resolve.iter().map(|s| s.as_str()).collect();
let tx_entries = d.get_transactions(&tx_hash_refs, false).await.unwrap();
let mut prepared_inputs = Vec::new();
for utxo in &selection.selected {
let output_row = wallet.get_output(&utxo.key_image).unwrap().unwrap();
let output_pub_key = hex_to_32(output_row.public_key.as_ref().unwrap());
// Find block height
let entry = tx_entries
.iter()
.zip(tx_hashes_to_resolve.iter())
.find(|(_, h)| **h == output_row.tx_hash)
.map(|(e, _)| e)
.unwrap();
// Resolve asset-type-specific index
let h_idx = (entry.block_height - dist[0].start_height) as usize;
let at_start = if h_idx == 0 {
0
} else {
dist[0].distribution[h_idx - 1]
};
let at_end = dist[0].distribution[h_idx];
let at_count = at_end - at_start;
let asset_type_index = if at_count == 1 {
at_start
} else {
let candidates: Vec<OutputRequest> = (at_start..at_end)
.map(|idx| OutputRequest {
amount: 0,
index: idx,
})
.collect();
let probe = d.get_outs(&candidates, false, tx_asset_type).await.unwrap();
probe
.iter()
.enumerate()
.find(|(_, out)| out.key == *output_row.public_key.as_ref().unwrap())
.map(|(i, _)| at_start + i as u64)
.expect("could not find asset-type index")
};
// Derive spending keys
let (secret_key, secret_key_y, public_key) = if output_row.is_carrot {
let prove_spend_key = keys.carrot.prove_spend_key.expect("not full wallet");
let generate_image_key = keys.carrot.generate_image_key;
let shared_secret = hex_to_32(output_row.carrot_shared_secret.as_ref().unwrap());
let commitment = if let Some(ref c) = output_row.commitment {
hex_to_32(c)
} else {
let amount = output_row.amount.parse::<u64>().unwrap();
to_32(&salvium_crypto::pedersen_commit(
&amount.to_le_bytes(),
&hex_to_32(output_row.mask.as_ref().unwrap()),
))
};
// Adjust keys for subaddress outputs.
let (adj_gik, adj_psk) = salvium_crypto::subaddress::carrot_adjust_keys_for_subaddress(
&generate_image_key,
&prove_spend_key,
&keys.carrot.generate_address_secret,
&keys.carrot.account_spend_pubkey,
output_row.subaddress_index.major as u32,
output_row.subaddress_index.minor as u32,
);
let (sk_x, sk_y) = salvium_crypto::carrot_scan::derive_carrot_spend_keys(
&adj_psk,
&adj_gik,
&shared_secret,
&commitment,
);
(sk_x, Some(sk_y), output_pub_key)
} else {
let spend_secret = keys.cn.spend_secret_key.expect("not full wallet");
let view_secret = keys.cn.view_secret_key;
let tx_pub_key = hex_to_32(output_row.tx_pub_key.as_ref().unwrap());
let sk = salvium_crypto::cn_scan::derive_output_spend_key(
&view_secret,
&spend_secret,
&tx_pub_key,
output_row.output_index as u32,
output_row.subaddress_index.major as u32,
output_row.subaddress_index.minor as u32,
);
let pk = to_32(&salvium_crypto::scalar_mult_base(&sk));
(sk, None, pk)
};
let mask = hex_to_32(output_row.mask.as_ref().unwrap());
let (ring_indices, real_pos) = decoy_selector
.build_ring(asset_type_index, DEFAULT_RING_SIZE)
.unwrap();
let out_requests: Vec<OutputRequest> = ring_indices
.iter()
.map(|&idx| OutputRequest {
amount: 0,
index: idx,
})
.collect();
let ring_members = d
.get_outs(&out_requests, false, tx_asset_type)
.await
.unwrap();
prepared_inputs.push(PreparedInput {
secret_key,
secret_key_y,
public_key,
amount: utxo.amount,
mask,
asset_type: tx_asset_type.to_string(),
global_index: asset_type_index,
ring: ring_members.iter().map(|m| hex_to_32(&m.key)).collect(),
ring_commitments: ring_members.iter().map(|m| hex_to_32(&m.mask)).collect(),
ring_indices,
real_index: real_pos,
});
}
// Build STAKE transaction
let mut builder = TransactionBuilder::new();
for input in prepared_inputs {
builder = builder.add_input(input);
}
builder = builder
.add_destination(Destination {
spend_pubkey: keys.carrot.account_spend_pubkey,
view_pubkey: keys.carrot.account_view_pubkey,
amount: STAKE_AMOUNT,
asset_type: tx_asset_type.to_string(),
payment_id: [0u8; 8],
is_subaddress: false,
})
.set_change_address(
keys.carrot.account_spend_pubkey,
keys.carrot.account_view_pubkey,
)
.set_change_view_balance_secret(keys.carrot.view_balance_secret)
.set_tx_type(tx_type::STAKE)
.set_unlock_time(unlock_time)
.set_asset_types(tx_asset_type, tx_asset_type)
.set_rct_type(rct_type::SALVIUM_ONE)
.set_fee(estimated_fee);
let unsigned = builder.build().expect("failed to build STAKE TX");
println!(
"Unsigned STAKE TX: {} inputs, {} outputs",
unsigned.inputs.len(),
unsigned.output_amounts.len()
);
// Verify prefix structure
assert_eq!(
unsigned.prefix.tx_type,
tx_type::STAKE,
"tx_type should be STAKE"
);
assert_eq!(
unsigned.prefix.unlock_time, unlock_time,
"unlock_time should match"
);
assert_eq!(
unsigned.prefix.amount_burnt, 0,
"amount_burnt should be 0 for STAKE"
);
let signed = sign_transaction(unsigned).expect("failed to sign STAKE TX");
let tx_bytes = signed.to_bytes().expect("failed to serialize");
let tx_hex = hex::encode(&tx_bytes);
let tx_hash = signed.tx_hash().expect("tx hash");
println!("STAKE TX hash: {}", hex::encode(tx_hash));
println!("STAKE TX size: {} bytes", tx_bytes.len());
// Submit
println!("\nSubmitting STAKE TX...");
let result = d
.send_raw_transaction_ex(&tx_hex, false, true, tx_asset_type)
.await
.unwrap();
println!("Status: {}", result.status);
if !result.reason.is_empty() {
println!("Reason: {}", result.reason);
}
assert_eq!(result.status, "OK", "STAKE TX rejected: {}", result.reason);
println!("\n=== STAKE TX submitted successfully ===");
}
// =============================================================================
// Test 3: STAKE return detection (sync after unlock)
// =============================================================================
#[tokio::test]
#[ignore]
async fn test_stake_return_detection() {
println!("\n=== STAKE Return Detection Test ===\n");
let dir = testnet_wallet_dir();
let wallet_json =
std::fs::read_to_string(dir.join("wallet-a.json")).expect("wallet-a.json not found");
let pin = std::fs::read_to_string(dir.join("wallet-a.pin"))
.expect("wallet-a.pin not found")
.trim()
.to_string();
let secrets = decrypt_js_wallet(&wallet_json, &pin).expect("failed to decrypt");
let temp_dir = tempfile::tempdir().unwrap();
let db_path = temp_dir.path().join("wallet-a.db");
let mut wallet = Wallet::create(
secrets.seed,
Network::Testnet,
db_path.to_str().unwrap(),
&[0u8; 32],
)
.expect("create wallet");
let d = daemon();
let sync_height = wallet
.sync(&d, None, &std::sync::atomic::AtomicBool::new(false))
.await
.expect("sync failed");
println!("Synced to height {}", sync_height);
// Look for protocol TX outputs (stake returns).
let db_asset_type = "SAL";
let balance = wallet.get_balance(db_asset_type, 0).unwrap();
let total: u64 = balance.balance.parse().unwrap();
let unlocked: u64 = balance.unlocked_balance.parse().unwrap();
println!(
"Balance: {:.9} SAL total, {:.9} SAL unlocked",
total as f64 / 1e9,
unlocked as f64 / 1e9
);
// If total > unlocked, there are locked outputs (possibly pending stakes).
let locked = total - unlocked;
if locked > 0 {
println!(
"Locked: {:.9} SAL (may include pending stake returns)",
locked as f64 / 1e9
);
}
println!("\nStake return detection complete.");
}