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)
This commit is contained in:
Matt Hess
2026-03-03 15:50:54 +00:00
parent 0459b3d0f9
commit a13c1d6451
21 changed files with 908 additions and 36 deletions
+11
View File
@@ -136,6 +136,17 @@ pub async fn sync_wallet(ctx: &AppContext) -> Result {
let wallet_height = wallet.sync_height().unwrap_or(0);
if wallet_height >= info.height {
println!("Wallet is already synchronized at height {}.", wallet_height);
// Still scan the mempool for pending transactions.
match wallet.scan_mempool(&pool).await {
Ok(result) if result.new_pool_txs > 0 || result.dropped_pool_txs > 0 => {
println!(
"Mempool: {} new, {} dropped",
result.new_pool_txs, result.dropped_pool_txs
);
}
Err(e) => log::warn!("mempool scan failed: {}", e),
_ => {}
}
return Ok(());
}
+10 -6
View File
@@ -24,9 +24,9 @@ impl Default for TransferFilters {
Self {
in_: true,
out: true,
pending: false,
pending: true,
failed: false,
pool: false,
pool: true,
coinbase: false,
burnt: false,
staked: false,
@@ -56,8 +56,11 @@ pub async fn show_transfers(ctx: &AppContext, f: &TransferFilters) -> Result {
} else {
None
},
is_confirmed: if f.pending { Some(false) } else { None },
in_pool: if f.pool { Some(true) } else { None },
// When both pending and pool are true (default), show everything —
// don't filter by confirmation status or pool membership.
// Only filter when the user explicitly requests ONLY pending or ONLY pool.
is_confirmed: if f.pending && !f.pool { Some(false) } else { None },
in_pool: if f.pool && !f.pending { Some(true) } else { None },
tx_type: if f.burnt {
Some(salvium_tx::types::tx_type::BURN as i64)
} else if f.staked {
@@ -83,7 +86,8 @@ pub async fn show_transfers(ctx: &AppContext, f: &TransferFilters) -> Result {
println!("{}", "-".repeat(80));
for tx in transfers.iter().rev().take(f.limit) {
let height = tx.block_height.unwrap_or(0);
let height_str =
if tx.in_pool { "pool".to_string() } else { tx.block_height.unwrap_or(0).to_string() };
let hash_short = if tx.tx_hash.len() > 16 {
format!("{}...", &tx.tx_hash[..16])
} else {
@@ -100,7 +104,7 @@ pub async fn show_transfers(ctx: &AppContext, f: &TransferFilters) -> Result {
println!(
"{:<8} {:<10} {:<8} {:>16} {}",
height,
height_str,
tx_type_name(tx.tx_type),
&tx.asset_type,
amount_str,
+2
View File
@@ -268,6 +268,8 @@ pub(crate) fn tx_type_name(t: i64) -> &'static str {
6 => "STAKE",
7 => "RETURN",
8 => "AUDIT",
9 => "CREATE_TOKEN",
10 => "ROLLUP",
_ => "UNKNOWN",
}
}
@@ -1084,3 +1084,102 @@ pub async fn sweep_account(
sweep_batched(&pipeline, inputs, &parsed_addr, asset).await
}
pub async fn create_token(
ctx: &AppContext,
ticker: &str,
supply: u64,
decimals: u64,
metadata: &str,
) -> Result {
let wallet = open_wallet(ctx)?;
if !wallet.can_spend() {
return Err("cannot create token from a view-only wallet".into());
}
// Validate token params.
salvium_wallet::validate_create_token_params(ticker, supply, decimals, metadata)?;
let fee_ctx =
tx_common::resolve_fee_context(&ctx.pool, salvium_tx::fee::FeePriority::Normal).await?;
let asset = "SAL1"; // CREATE_TOKEN always uses SAL1
let cost = salvium_wallet::CREATE_TOKEN_COST;
println!("Create Token:");
println!(" Ticker: {}", ticker);
println!(" Supply: {}", supply);
println!(" Decimals: {}", decimals);
println!(" Cost: {} SAL1", format_sal_u64(cost));
println!();
let est_fee = salvium_tx::estimate_tx_fee(2, 2, 16, true, 0x04, fee_ctx.fee_per_byte);
println!(" Est. fee: {} SAL1", format_sal_u64(est_fee));
println!();
let balance = wallet.get_balance(asset, 0)?;
let unlocked: u64 = balance.unlocked_balance.parse().unwrap_or(0);
if unlocked < cost + est_fee {
return Err(format!(
"insufficient balance: have {} SAL1, need {} SAL1",
format_sal_u64(unlocked),
format_sal_u64(cost + est_fee),
)
.into());
}
if !tx_common::confirm("Confirm token creation? [y/N] ")? {
println!("Token creation cancelled.");
return Ok(());
}
let pipeline = TxPipeline::new(&wallet, ctx, &fee_ctx);
let (inputs, actual_fee) = pipeline.select_and_prepare_inputs(
cost,
est_fee,
asset,
salvium_wallet::utxo::SelectionStrategy::Default,
)?;
let prepared = pipeline.fetch_decoys(&inputs, asset).await?;
// Build token metadata.
let token_metadata = salvium_tx::types::TokenMetadata {
version: 1,
asset_type: ticker.to_string(),
token: salvium_tx::types::TokenVariant::Sal(salvium_tx::types::SalToken {
version: 1,
supply,
decimals: decimals as u8,
metadata: metadata.to_string(),
url: String::new(),
signature: [0u8; 32],
}),
};
// Send cost to self (change address).
let keys = wallet.keys();
let builder = salvium_tx::TransactionBuilder::new()
.add_inputs(prepared)
.add_destination(salvium_tx::builder::Destination {
spend_pubkey: keys.carrot.account_spend_pubkey,
view_pubkey: keys.carrot.account_view_pubkey,
amount: cost,
asset_type: asset.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(salvium_tx::types::tx_type::CREATE_TOKEN)
.set_fee(actual_fee)
.set_asset_types(asset, asset)
.set_token_metadata(token_metadata);
let result = pipeline.build_sign_submit(builder, asset).await?;
println!("Token created successfully!");
println!(" TX hash: {}", hex::encode(result.tx_hash));
println!(" Ticker: {}", ticker);
println!(" Fee: {} SAL1", format_sal_u64(actual_fee));
Ok(())
}
+18
View File
@@ -284,6 +284,21 @@ enum Commands {
#[arg(long, default_value = "")]
asset: String,
},
/// Create a custom token (costs 1000 SAL1).
CreateToken {
/// 4-character uppercase ticker (e.g. "TEST").
#[arg(long)]
ticker: String,
/// Total supply in atomic units.
#[arg(long)]
supply: u64,
/// Number of decimal places (0-8).
#[arg(long, default_value = "8")]
decimals: u64,
/// Token metadata string.
#[arg(long, default_value = "")]
metadata: String,
},
/// Sweep all funds to an address.
SweepAll {
#[arg(long)]
@@ -997,6 +1012,9 @@ async fn main() {
commands::convert(&ctx, &amount, &source, &dest, &priority).await
}
Commands::Audit { priority, asset } => commands::audit(&ctx, &priority, &asset).await,
Commands::CreateToken { ticker, supply, decimals, metadata } => {
commands::create_token(&ctx, &ticker, supply, decimals, &metadata).await
}
Commands::SweepAll { address, priority, asset } => {
commands::sweep_all(&ctx, &address, &priority, &asset).await
}
+96 -5
View File
@@ -15,7 +15,7 @@ use salvium_types::consensus::{
};
use salvium_types::constants::{
network_config, HfVersion, Network, RctType, TxType, DEFAULT_RING_SIZE, DISPLAY_DECIMAL_POINT,
TRANSACTION_VERSION_2_OUTS, TRANSACTION_VERSION_CARROT,
TRANSACTION_VERSION_2_OUTS, TRANSACTION_VERSION_CARROT, TRANSACTION_VERSION_ENABLE_TOKENS,
};
use thiserror::Error;
@@ -134,6 +134,15 @@ pub enum ValidationError {
#[error("AUDIT transactions must have positive unlock height")]
AuditZeroUnlockHeight,
#[error("CREATE_TOKEN transactions not enabled before HF {0}")]
CreateTokenNotEnabled(u8),
#[error("ROLLUP transactions not enabled before HF {0}")]
RollupNotEnabled(u8),
#[error("CREATE_TOKEN/ROLLUP source and dest must be SAL1")]
TokenRollupAssetMismatch,
}
// =============================================================================
@@ -182,9 +191,9 @@ pub fn validate_tx_type_and_version(
return Err(ValidationError::TxTypeUnset);
}
// TX type must be in valid range (1-8)
// TX type must be in valid range (1-10)
let type_val = tx_type as u16;
if !(1..=8).contains(&type_val) {
if !(1..=10).contains(&type_val) {
return Err(ValidationError::InvalidTxType(type_val));
}
@@ -193,8 +202,32 @@ pub fn validate_tx_type_and_version(
return Err(ValidationError::InvalidTxVersion { version, hf_version });
}
// Carrot fork requirements
if hf_version >= HfVersion::CARROT
// CREATE_TOKEN requires ENABLE_TOKENS HF
if tx_type == TxType::CreateToken && hf_version < HfVersion::ENABLE_TOKENS {
return Err(ValidationError::CreateTokenNotEnabled(hf_version));
}
// ROLLUP requires ENABLE_TOKENS HF
if tx_type == TxType::Rollup && hf_version < HfVersion::ENABLE_TOKENS {
return Err(ValidationError::RollupNotEnabled(hf_version));
}
// ENABLE_TOKENS fork: non-coinbase user tx types need version 5
if hf_version >= HfVersion::ENABLE_TOKENS
&& tx_type != TxType::Transfer
&& tx_type != TxType::Miner
&& tx_type != TxType::Protocol
&& version != TRANSACTION_VERSION_ENABLE_TOKENS
{
return Err(ValidationError::TxVersionMismatch {
tx_type: tx_type.to_string(),
required: TRANSACTION_VERSION_ENABLE_TOKENS,
hf_version,
});
}
// Carrot fork requirements (HF10, before ENABLE_TOKENS supersedes)
if (HfVersion::CARROT..HfVersion::ENABLE_TOKENS).contains(&hf_version)
&& tx_type != TxType::Transfer
&& tx_type != TxType::Miner
&& tx_type != TxType::Protocol
@@ -264,6 +297,14 @@ pub fn validate_asset_types(
return Err(ValidationError::SpendBurn);
}
// CREATE_TOKEN and ROLLUP: source and dest must be SAL1
if tx_type == TxType::CreateToken || tx_type == TxType::Rollup {
if source_asset != "SAL1" || dest_asset != "SAL1" {
return Err(ValidationError::TokenRollupAssetMismatch);
}
return Ok(());
}
// CONVERT allows different source and dest (but still subject to HF asset check)
// AUDIT has special rules (handled below)
@@ -1134,6 +1175,56 @@ mod tests {
assert!(matches!(result.unwrap_err(), ValidationError::NoInputs));
}
// =========================================================================
// CREATE_TOKEN / ROLLUP validation tests
// =========================================================================
#[test]
fn test_create_token_before_hf11() {
let result = validate_tx_type_and_version(TxType::CreateToken, 5, 10);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ValidationError::CreateTokenNotEnabled(10)));
}
#[test]
fn test_create_token_at_hf11() {
let result = validate_tx_type_and_version(TxType::CreateToken, 5, 11);
assert!(result.is_ok());
}
#[test]
fn test_rollup_before_hf11() {
let result = validate_tx_type_and_version(TxType::Rollup, 5, 10);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), ValidationError::RollupNotEnabled(10)));
}
#[test]
fn test_rollup_at_hf11() {
let result = validate_tx_type_and_version(TxType::Rollup, 5, 11);
assert!(result.is_ok());
}
#[test]
fn test_create_token_wrong_version_at_hf11() {
// At HF11, CREATE_TOKEN with version 4 should fail (needs 5)
let result = validate_tx_type_and_version(TxType::CreateToken, 4, 11);
assert!(result.is_err());
}
#[test]
fn test_create_token_asset_types() {
assert!(validate_asset_types(TxType::CreateToken, "SAL1", "SAL1", 11).is_ok());
assert!(validate_asset_types(TxType::CreateToken, "SAL", "SAL1", 11).is_err());
assert!(validate_asset_types(TxType::CreateToken, "SAL1", "SAL", 11).is_err());
}
#[test]
fn test_rollup_asset_types() {
assert!(validate_asset_types(TxType::Rollup, "SAL1", "SAL1", 11).is_ok());
assert!(validate_asset_types(TxType::Rollup, "SAL", "SAL1", 11).is_err());
}
#[test]
fn test_audit_all_valid_fields() {
// Verify all fields are checked in a valid case
+212 -1
View File
@@ -26,6 +26,7 @@ use salvium_wallet::Wallet;
/// Transfer parameters (deserialized from JSON).
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TransferParams {
pub destinations: Vec<DestinationParam>,
#[serde(default)]
@@ -40,6 +41,7 @@ pub struct TransferParams {
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DestinationParam {
pub address: String,
pub amount: String,
@@ -47,6 +49,7 @@ pub struct DestinationParam {
/// Stake parameters (deserialized from JSON).
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StakeParams {
pub amount: String,
#[serde(default)]
@@ -59,6 +62,7 @@ pub struct StakeParams {
/// Sweep parameters (deserialized from JSON).
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SweepParams {
pub address: String,
#[serde(default)]
@@ -391,6 +395,84 @@ pub unsafe extern "C" fn salvium_wallet_transfer_dry_run(
})
}
/// Create Token parameters (deserialized from JSON).
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateTokenParams {
pub ticker: String,
pub supply: u64,
#[serde(default = "default_decimals")]
pub decimals: u64,
#[serde(default)]
pub metadata: String,
#[serde(default = "default_priority")]
pub priority: String,
#[serde(default = "default_ring_size")]
pub ring_size: usize,
#[serde(default)]
pub dry_run: bool,
}
fn default_decimals() -> u64 {
8
}
/// Create a custom token.
///
/// `params_json` schema:
/// ```json
/// {
/// "ticker": "TEST",
/// "supply": 1000000,
/// "decimals": 8,
/// "metadata": "A test token",
/// "priority": "normal",
/// "ring_size": 16,
/// "dry_run": false
/// }
/// ```
///
/// Returns JSON on success: `{"tx_hash": "...", "fee": "...", "ticker": "..."}`
/// Returns null on error.
/// Caller must free with `salvium_string_free()`.
#[no_mangle]
pub unsafe extern "C" fn salvium_wallet_create_token(
wallet: *mut c_void,
daemon: *mut c_void,
params_json: *const c_char,
) -> *mut c_char {
ffi_try_string(|| {
let wh = unsafe { borrow_handle::<crate::wallet::WalletHandle>(wallet) }?;
let dh = unsafe { borrow_handle::<crate::daemon::DaemonHandle>(daemon) }?;
let json_str = unsafe { c_str_to_str(params_json) }?;
let params: CreateTokenParams =
serde_json::from_str(json_str).map_err(|e| format!("invalid params: {e}"))?;
if !wh.wallet.can_spend() {
return Err("wallet is view-only".into());
}
// Validate token params.
salvium_wallet::validate_create_token_params(
&params.ticker,
params.supply,
params.decimals,
&params.metadata,
)
.map_err(|e| e.to_string())?;
let priority = parse_priority(&params.priority);
let rt = crate::runtime();
rt.block_on(async {
let fee_ctx = resolve_fee_context(&dh.pool, priority).await?;
let daemon = dh.pool.active_daemon().await;
do_create_token(&wh.wallet, &daemon, &params, fee_ctx.fee_per_byte).await
})
})
}
// =============================================================================
// Internal Transfer Flow
// =============================================================================
@@ -688,6 +770,93 @@ pub async fn do_sweep(
serde_json::to_string(&result).map_err(|e| e.to_string())
}
pub async fn do_create_token(
wallet: &Wallet,
daemon: &DaemonRpc,
params: &CreateTokenParams,
fee_per_byte: u64,
) -> Result<String, String> {
let (fork_rct, is_carrot, _) = detect_fork_params(daemon).await?;
let asset_type = "SAL1";
let cost = salvium_wallet::token::CREATE_TOKEN_COST;
// Estimate fee: 2 inputs (cost + change), 2 outputs (self + change).
let out_type = if is_carrot { output_type::CARROT_V1 } else { output_type::TAGGED_KEY };
let est_fee = fee::estimate_tx_fee(2, 2, params.ring_size, is_carrot, out_type, fee_per_byte);
// Select UTXOs.
let selection = wallet
.select_outputs(cost, est_fee, asset_type, salvium_wallet::SelectionStrategy::Default)
.map_err(|e| format!("UTXO selection failed: {e}"))?;
// Build token metadata.
let token_metadata = salvium_tx::types::TokenMetadata {
version: 1,
asset_type: params.ticker.clone(),
token: salvium_tx::types::TokenVariant::Sal(salvium_tx::types::SalToken {
version: 1,
supply: params.supply,
decimals: params.decimals as u8,
metadata: params.metadata.clone(),
url: String::new(),
signature: [0u8; 32],
}),
};
// CREATE_TOKEN sends cost to self. Use build_sign_maybe_broadcast with
// a self-destination and the token metadata set on the builder afterward.
// Since build_sign_maybe_broadcast doesn't support token_metadata directly,
// we use a destination to self for the cost amount.
let keys = wallet.keys();
let (chg_spend, chg_view) = if is_carrot {
(keys.carrot.account_spend_pubkey, keys.carrot.account_view_pubkey)
} else {
(keys.cn.spend_public_key, keys.cn.view_public_key)
};
let self_dest = Destination {
spend_pubkey: chg_spend,
view_pubkey: chg_view,
amount: cost,
asset_type: asset_type.to_string(),
payment_id: [0u8; 8],
is_subaddress: false,
};
// We need to manually build the TX since build_sign_maybe_broadcast
// doesn't support setting token_metadata. Reuse its UTXO/decoy pipeline.
let built = build_sign_maybe_broadcast_with_metadata(
wallet,
daemon,
&[self_dest],
&selection,
asset_type,
tx_type::CREATE_TOKEN,
params.ring_size,
FeePriority::Normal,
fee_per_byte,
0, // amount_burnt
0, // unlock_time
fork_rct,
is_carrot,
params.dry_run,
Some(token_metadata),
)
.await?;
let mut result = serde_json::json!({
"tx_hash": built.tx_hash,
"fee": built.fee.to_string(),
"ticker": params.ticker,
"supply": params.supply.to_string(),
"weight": built.weight,
});
if params.dry_run {
result["tx_hex"] = serde_json::Value::String(built.tx_hex);
}
serde_json::to_string(&result).map_err(|e| e.to_string())
}
/// Result of building (and optionally broadcasting) a transaction.
pub struct BuiltTx {
pub tx_hash: String,
@@ -698,7 +867,7 @@ pub struct BuiltTx {
pub fee_per_byte: u64,
}
/// Core TX construction pipeline shared by transfer, stake, and sweep.
/// Core TX construction pipeline shared by transfer, stake, sweep, and create_token.
#[allow(clippy::too_many_arguments)]
pub async fn build_sign_maybe_broadcast(
wallet: &Wallet,
@@ -715,6 +884,45 @@ pub async fn build_sign_maybe_broadcast(
fork_rct_type: u8,
is_carrot: bool,
dry_run: bool,
) -> Result<BuiltTx, String> {
build_sign_maybe_broadcast_with_metadata(
wallet,
daemon,
destinations,
selection,
asset_type,
tt,
ring_size,
priority,
fee_per_byte,
amount_burnt,
unlock_time,
fork_rct_type,
is_carrot,
dry_run,
None,
)
.await
}
/// Core TX construction pipeline with optional token metadata.
#[allow(clippy::too_many_arguments)]
pub async fn build_sign_maybe_broadcast_with_metadata(
wallet: &Wallet,
daemon: &DaemonRpc,
destinations: &[Destination],
selection: &salvium_wallet::utxo::SelectionResult,
asset_type: &str,
tt: u8,
ring_size: usize,
priority: FeePriority,
fee_per_byte: u64,
amount_burnt: u64,
unlock_time: u64,
fork_rct_type: u8,
is_carrot: bool,
dry_run: bool,
token_metadata: Option<salvium_tx::types::TokenMetadata>,
) -> Result<BuiltTx, String> {
let keys = wallet.keys();
@@ -951,6 +1159,9 @@ pub async fn build_sign_maybe_broadcast(
if amount_burnt > 0 {
builder = builder.set_amount_burnt(amount_burnt);
}
if let Some(ref tm) = token_metadata {
builder = builder.set_token_metadata(tm.clone());
}
for dest in destinations {
builder = builder.add_destination(dest.clone());
}
+41
View File
@@ -469,6 +469,27 @@ pub struct SignMultisigResult {
pub tx_hash_list: Vec<String>,
}
// =============================================================================
// Token Creation Types
// =============================================================================
/// Request body for `create_token` RPC method.
#[derive(Debug, Serialize)]
pub struct CreateTokenRequest {
pub ticker: String,
pub supply: u64,
pub decimals: u64,
#[serde(skip_serializing_if = "String::is_empty")]
pub metadata: String,
}
/// Response from `create_token` RPC method.
#[derive(Debug, Deserialize)]
pub struct CreateTokenResult {
pub tx_hash: String,
pub fee: u64,
}
// =============================================================================
// WalletRpc
// =============================================================================
@@ -957,6 +978,26 @@ impl WalletRpc {
Ok(serde_json::from_value(val)?)
}
/// Create a custom token.
pub async fn create_token(
&self,
req: &CreateTokenRequest,
) -> Result<CreateTokenResult, RpcError> {
let val = self
.client
.call(
"create_token",
serde_json::json!({
"ticker": req.ticker,
"supply": req.supply,
"decimals": req.decimals,
"metadata": req.metadata,
}),
)
.await?;
Ok(serde_json::from_value(val)?)
}
/// Relay a previously created but not relayed transaction.
pub async fn relay_tx(&self, hex: &str) -> Result<String, RpcError> {
let val = self.client.call("relay_tx", serde_json::json!({ "hex": hex })).await?;
+6
View File
@@ -354,6 +354,9 @@ mod tests {
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,
},
rct: Some(RctSignatures {
rct_type: rct_type::SALVIUM_ONE,
@@ -427,6 +430,9 @@ mod tests {
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,
},
rct: None,
};
+48 -2
View File
@@ -97,6 +97,10 @@ pub struct TransactionBuilder {
rct_type: u8,
/// Sender's view secret key (needed for STAKE/AUDIT return address derivation).
view_secret_key: Option<[u8; 32]>,
// Salvium 2 (v5) fields.
token_metadata: Option<TokenMetadata>,
rollup_binding_tag: Option<RollupBindingTag>,
layer2_rollup_data: Option<Layer2RollupData>,
}
impl TransactionBuilder {
@@ -118,6 +122,9 @@ impl TransactionBuilder {
amount_slippage_limit: 0,
rct_type: rct_type::SALVIUM_ONE,
view_secret_key: None,
token_metadata: None,
rollup_binding_tag: None,
layer2_rollup_data: None,
}
}
@@ -207,6 +214,24 @@ impl TransactionBuilder {
self
}
/// Set token metadata (for CREATE_TOKEN transactions, v5+).
pub fn set_token_metadata(mut self, metadata: TokenMetadata) -> Self {
self.token_metadata = Some(metadata);
self
}
/// Set rollup binding tag (for TRANSFER/ROLLUP transactions, v5+).
pub fn set_rollup_binding_tag(mut self, tag: RollupBindingTag) -> Self {
self.rollup_binding_tag = Some(tag);
self
}
/// Set layer 2 rollup data (for ROLLUP transactions, v5+).
pub fn set_layer2_rollup_data(mut self, data: Layer2RollupData) -> Self {
self.layer2_rollup_data = Some(data);
self
}
/// Build an unsigned transaction.
///
/// This computes the fee, creates outputs (including change), builds the
@@ -569,8 +594,19 @@ impl TransactionBuilder {
}
}
// Version 4 for CARROT transactions (rct_type >= SALVIUM_ONE), version 2 otherwise.
let version = if self.rct_type >= rct_type::SALVIUM_ONE { 4 } else { 2 };
// Version 5 for ENABLE_TOKENS tx types (CREATE_TOKEN, ROLLUP) or when
// token_metadata / layer2_rollup_data is set; version 4 for CARROT; else 2.
let version = if self.token_metadata.is_some()
|| self.layer2_rollup_data.is_some()
|| self.tx_type == tx_type::CREATE_TOKEN
|| self.tx_type == tx_type::ROLLUP
{
5
} else if self.rct_type >= rct_type::SALVIUM_ONE {
4
} else {
2
};
// For version >= 3 TRANSFER, populate return_address_list and change_mask.
let (return_address_list, return_address_change_mask) = if self.tx_type == tx_type::TRANSFER
@@ -694,6 +730,13 @@ impl TransactionBuilder {
None
};
// Salvium 2 (v5) fields — only populated for version >= 5.
let (rollup_binding_tag, token_metadata, layer2_rollup_data) = if version >= 5 {
(self.rollup_binding_tag, self.token_metadata, self.layer2_rollup_data)
} else {
(None, None, None)
};
let prefix = TxPrefix {
version,
unlock_time: self.unlock_time,
@@ -710,6 +753,9 @@ impl TransactionBuilder {
source_asset_type: self.source_asset_type,
destination_asset_type: self.destination_asset_type,
amount_slippage_limit: self.amount_slippage_limit,
rollup_binding_tag,
token_metadata,
layer2_rollup_data,
};
Ok(UnsignedTransaction {
+4 -1
View File
@@ -17,7 +17,10 @@ pub use builder::TransactionBuilder;
pub use decoy::DecoySelector;
pub use fee::{calculate_fee_from_weight, estimate_tx_fee};
pub use sign::sign_transaction;
pub use types::{ProtocolTxData, RctSignatures, Transaction, TxInput, TxOutput, TxPrefix};
pub use types::{
ErcToken, Layer2RollupData, Layer2RollupTx, ProtocolTxData, RctSignatures, RollupBindingTag,
SalToken, TokenMetadata, TokenVariant, Transaction, TxInput, TxOutput, TxPrefix,
};
use thiserror::Error;
+15
View File
@@ -630,6 +630,9 @@ mod tests {
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_amount],
@@ -742,6 +745,9 @@ mod tests {
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_amount],
@@ -828,6 +834,9 @@ mod tests {
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_amount],
@@ -909,6 +918,9 @@ mod tests {
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_amount],
@@ -1019,6 +1031,9 @@ mod tests {
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_amount],
+145
View File
@@ -5,6 +5,7 @@
//! and to/from raw bytes.
use crate::TxError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ─── Transaction Constants ──────────────────────────────────────────────────
@@ -19,6 +20,8 @@ pub mod tx_type {
pub const STAKE: u8 = 6;
pub const RETURN: u8 = 7;
pub const AUDIT: u8 = 8;
pub const CREATE_TOKEN: u8 = 9;
pub const ROLLUP: u8 = 10;
}
pub mod rct_type {
@@ -51,6 +54,78 @@ pub struct ProtocolTxData {
pub return_anchor_enc: [u8; 16],
}
// ─── Salvium 2 Token / Rollup Types ─────────────────────────────────────────
/// Rollup binding tag — 8 bytes linking TRANSFER/ROLLUP transactions.
/// C++ ref: carrot_core/core_types.h rollup_binding_tag_t
pub type RollupBindingTag = [u8; 8];
/// ERC20 bridge token definition.
/// C++ ref: cryptonote_basic.h erc_token_t
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ErcToken {
pub version: u8,
pub contract_address: String,
pub lockbox_address: String,
pub ticker: String,
pub erc20_asset_id: u64,
}
/// Native Salvium token definition.
/// C++ ref: cryptonote_basic.h sal_token_t
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SalToken {
pub version: u8,
pub supply: u64,
pub decimals: u8,
pub metadata: String,
pub url: String,
pub signature: [u8; 32],
}
/// Token variant — either ERC20 bridge or native SAL token.
/// C++ ref: cryptonote_basic.h token_v (boost::variant<erc_token_t, sal_token_t>)
/// Variant tags: 0 = ERC20, 1 = SAL
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum TokenVariant {
#[serde(rename = "erc20")]
Erc20(ErcToken),
#[serde(rename = "sal")]
Sal(SalToken),
}
/// Token metadata attached to CREATE_TOKEN transactions.
/// C++ ref: cryptonote_basic.h token_metadata_t
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenMetadata {
pub version: u8,
pub asset_type: String,
pub token: TokenVariant,
}
/// Single transaction in a Layer 2 rollup bundle.
/// C++ ref: cryptonote_basic.h layer2_rollup_tx_t
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Layer2RollupTx {
pub tx_prefix_hash: [u8; 32],
pub first_key_image: [u8; 32],
pub tx_fee: u64,
}
/// Layer 2 rollup data container.
/// C++ ref: cryptonote_basic.h layer2_rollup_data_t
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Layer2RollupData {
pub version: u8,
pub txs: Vec<Layer2RollupTx>,
}
// ─── Core Transaction Types ─────────────────────────────────────────────────
/// Complete transaction (prefix + RingCT signatures).
@@ -79,6 +154,13 @@ pub struct TxPrefix {
pub source_asset_type: String,
pub destination_asset_type: String,
pub amount_slippage_limit: u64,
// Salvium 2 (TX version >= 5) fields.
/// Rollup binding tag — links TRANSFER/ROLLUP TXs (v5, TRANSFER and ROLLUP only).
pub rollup_binding_tag: Option<RollupBindingTag>,
/// Token metadata — CREATE_TOKEN transactions only (v5).
pub token_metadata: Option<TokenMetadata>,
/// Layer 2 rollup data — ROLLUP transactions only (v5).
pub layer2_rollup_data: Option<Layer2RollupData>,
}
/// Transaction input.
@@ -352,6 +434,38 @@ impl TxPrefix {
})
});
// Salvium 2 (v5) fields — conditionally parsed.
let rollup_binding_tag = if version >= 5 {
v.get("rollup_binding_tag")
.and_then(|s| s.as_str())
.and_then(|s| hex::decode(s).ok())
.and_then(|b| if b.len() == 8 { Some(hex_to_8(&b)) } else { None })
} else {
None
};
let token_metadata = if version >= 5 {
v.get("token_metadata").and_then(|tm| {
if tm.is_null() {
return None;
}
serde_json::from_value(tm.clone()).ok()
})
} else {
None
};
let layer2_rollup_data = if version >= 5 {
v.get("layer2_rollup_data").and_then(|rd| {
if rd.is_null() {
return None;
}
serde_json::from_value(rd.clone()).ok()
})
} else {
None
};
Ok(Self {
version,
unlock_time,
@@ -368,6 +482,9 @@ impl TxPrefix {
source_asset_type,
destination_asset_type,
amount_slippage_limit,
rollup_binding_tag,
token_metadata,
layer2_rollup_data,
})
}
@@ -412,6 +529,25 @@ impl TxPrefix {
});
}
// Salvium 2 (v5) fields — conditionally serialized by tx_type.
if self.version >= 5 {
if let Some(ref tag) = self.rollup_binding_tag {
if self.tx_type == tx_type::TRANSFER || self.tx_type == tx_type::ROLLUP {
obj["rollup_binding_tag"] = Value::String(hex::encode(tag));
}
}
if let Some(ref tm) = self.token_metadata {
if self.tx_type == tx_type::CREATE_TOKEN {
obj["token_metadata"] = serde_json::to_value(tm).unwrap_or(Value::Null);
}
}
if let Some(ref rd) = self.layer2_rollup_data {
if self.tx_type == tx_type::ROLLUP {
obj["layer2_rollup_data"] = serde_json::to_value(rd).unwrap_or(Value::Null);
}
}
}
obj
}
}
@@ -753,6 +889,12 @@ fn to_32(v: &[u8]) -> [u8; 32] {
arr
}
fn hex_to_8(b: &[u8]) -> [u8; 8] {
let mut arr = [0u8; 8];
arr.copy_from_slice(&b[..8]);
arr
}
fn hex_to_32(s: &str) -> Option<[u8; 32]> {
let bytes = hex::decode(s).ok()?;
if bytes.len() != 32 {
@@ -958,6 +1100,9 @@ mod tests {
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,
};
let json = prefix.to_json();
+3
View File
@@ -357,6 +357,9 @@ async fn test_build_and_sign_with_real_decoys() {
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],
+27 -6
View File
@@ -194,10 +194,17 @@ pub fn is_carrot_active(height: u64, network: Network) -> bool {
is_hf_active(HfVersion::CARROT, height, network)
}
/// Check if token support (HF11) is active at a given height.
pub fn is_tokens_active(height: u64, network: Network) -> bool {
is_hf_active(HfVersion::ENABLE_TOKENS, height, network)
}
/// Get the correct TX version for a given TX type and height.
pub fn tx_version(tx_type: TxType, height: u64, network: Network) -> u8 {
let hf = hf_version_for_height(height, network);
if hf >= HfVersion::CARROT {
if hf >= HfVersion::ENABLE_TOKENS {
5
} else if hf >= HfVersion::CARROT {
4
} else if hf >= HfVersion::ENABLE_N_OUTS && tx_type == TxType::Transfer {
3
@@ -691,9 +698,14 @@ mod tests {
// HF10 at exact activation
assert_eq!(hf_version_for_height(1100, net), 10);
// Just below HF11
assert_eq!(hf_version_for_height(1199, net), 10);
// HF11 at exact activation
assert_eq!(hf_version_for_height(1200, net), 11);
// Well beyond all forks
assert_eq!(hf_version_for_height(2000, net), 10);
assert_eq!(hf_version_for_height(100_000, net), 10);
assert_eq!(hf_version_for_height(2000, net), 11);
assert_eq!(hf_version_for_height(100_000, net), 11);
}
/// Verify TX version selection for each TX type across HF boundaries.
@@ -744,9 +756,18 @@ mod tests {
assert_eq!(tx_version(TxType::Convert, 1100, net), 4);
assert_eq!(tx_version(TxType::Audit, 1100, net), 4);
// --- Well beyond HF10 (height 2000): still v4 ---
assert_eq!(tx_version(TxType::Transfer, 2000, net), 4);
assert_eq!(tx_version(TxType::Stake, 2000, net), 4);
// --- HF11 (height 1200): everything upgrades to v5 ---
assert_eq!(tx_version(TxType::Transfer, 1200, net), 5);
assert_eq!(tx_version(TxType::Stake, 1200, net), 5);
assert_eq!(tx_version(TxType::Burn, 1200, net), 5);
assert_eq!(tx_version(TxType::Convert, 1200, net), 5);
assert_eq!(tx_version(TxType::Audit, 1200, net), 5);
assert_eq!(tx_version(TxType::CreateToken, 1200, net), 5);
assert_eq!(tx_version(TxType::Rollup, 1200, net), 5);
// --- Well beyond HF11 (height 2000): still v5 ---
assert_eq!(tx_version(TxType::Transfer, 2000, net), 5);
assert_eq!(tx_version(TxType::Stake, 2000, net), 5);
}
/// Verify RCT type progression: 6 -> 7 -> 8 -> 9 across hard forks.
+35 -6
View File
@@ -237,6 +237,8 @@ pub enum TxType {
Stake = 6,
Return = 7,
Audit = 8,
CreateToken = 9,
Rollup = 10,
}
impl TxType {
@@ -251,6 +253,8 @@ impl TxType {
6 => Some(Self::Stake),
7 => Some(Self::Return),
8 => Some(Self::Audit),
9 => Some(Self::CreateToken),
10 => Some(Self::Rollup),
_ => None,
}
}
@@ -268,6 +272,8 @@ impl std::fmt::Display for TxType {
Self::Stake => write!(f, "STAKE"),
Self::Return => write!(f, "RETURN"),
Self::Audit => write!(f, "AUDIT"),
Self::CreateToken => write!(f, "CREATE_TOKEN"),
Self::Rollup => write!(f, "ROLLUP"),
}
}
}
@@ -316,8 +322,8 @@ impl RctType {
// Transaction Versions
// =============================================================================
/// Current transaction version (CARROT).
pub const CURRENT_TRANSACTION_VERSION: u8 = 4;
/// Current transaction version (token-enabled).
pub const CURRENT_TRANSACTION_VERSION: u8 = 5;
/// TX version supporting 2 outputs.
pub const TRANSACTION_VERSION_2_OUTS: u8 = 2;
@@ -328,6 +334,9 @@ pub const TRANSACTION_VERSION_N_OUTS: u8 = 3;
/// TX version with CARROT support.
pub const TRANSACTION_VERSION_CARROT: u8 = 4;
/// TX version with token support (HF11).
pub const TRANSACTION_VERSION_ENABLE_TOKENS: u8 = 5;
// =============================================================================
// Hard Fork Versions
// =============================================================================
@@ -367,6 +376,7 @@ impl HfVersion {
pub const AUDIT2: u8 = 8;
pub const AUDIT2_PAUSE: u8 = 9;
pub const CARROT: u8 = 10;
pub const ENABLE_TOKENS: u8 = 11;
// Future (placeholder)
pub const REQUIRE_VIEW_TAGS: u8 = 255;
@@ -421,6 +431,23 @@ impl std::fmt::Display for AssetType {
}
}
// =============================================================================
// Token Types
// =============================================================================
/// Token type identifiers for CREATE_TOKEN transactions.
pub mod token_type {
pub const UNSET: u8 = 0;
pub const ERC20: u8 = 1;
pub const SAL: u8 = 2;
}
/// Size of a rollup binding tag in bytes.
pub const ROLLUP_BINDING_TAG_BYTES: usize = 8;
/// Lock period (in blocks) for CREATE_TOKEN transactions.
pub const CREATE_TOKEN_LOCK_PERIOD: u64 = 10;
// =============================================================================
// Network Configuration
// =============================================================================
@@ -464,7 +491,7 @@ static MAINNET_HF_HEIGHTS: [(u8, u64); 10] = [
];
// Testnet hard fork heights
static TESTNET_HF_HEIGHTS: [(u8, u64); 10] = [
static TESTNET_HF_HEIGHTS: [(u8, u64); 11] = [
(1, 1),
(2, 250),
(3, 500),
@@ -475,10 +502,11 @@ static TESTNET_HF_HEIGHTS: [(u8, u64); 10] = [
(8, 950),
(9, 1000),
(10, 1100),
(11, 1200),
];
// Stagenet hard fork heights (matches testnet)
static STAGENET_HF_HEIGHTS: [(u8, u64); 10] = [
static STAGENET_HF_HEIGHTS: [(u8, u64); 11] = [
(1, 1),
(2, 250),
(3, 500),
@@ -489,6 +517,7 @@ static STAGENET_HF_HEIGHTS: [(u8, u64); 10] = [
(8, 950),
(9, 1000),
(10, 1100),
(11, 1200),
];
pub static MAINNET_CONFIG: NetworkConfig = NetworkConfig {
@@ -648,10 +677,10 @@ mod tests {
#[test]
fn test_tx_type_roundtrip() {
for v in 0..=8u16 {
for v in 0..=10u16 {
let tt = TxType::from_u16(v).unwrap();
assert_eq!(tt as u16, v);
}
assert!(TxType::from_u16(9).is_none());
assert!(TxType::from_u16(11).is_none());
}
}
+3 -1
View File
@@ -12,4 +12,6 @@ pub mod mnemonic;
pub mod wordlists;
pub use address::ParsedAddress;
pub use constants::{AddressFormat, AddressType, HfVersion, Network, RctType, TxType};
pub use constants::{
AddressFormat, AddressType, HfVersion, Network, RctType, TxType, ROLLUP_BINDING_TAG_BYTES,
};
+4
View File
@@ -20,6 +20,7 @@ pub mod query;
pub mod scanner;
pub mod stake;
pub mod sync;
pub mod token;
pub mod utxo;
pub mod wallet;
@@ -27,9 +28,12 @@ pub use account::Account;
pub use error::WalletError;
pub use js_import::{decrypt_js_wallet, JsWalletSecrets};
pub use keys::{CarrotKeys, CnKeys, WalletKeys, WalletType};
#[cfg(not(target_arch = "wasm32"))]
pub use pool_scan::PoolScanResult;
pub use pqc::{decrypt_envelope, encrypt_envelope, PqcEnvelope, WalletSecrets};
pub use scanner::{FoundOutput, ScanContext};
pub use sync::{SyncEngine, SyncEvent};
pub use token::{validate_create_token_params, CreateTokenParams, CREATE_TOKEN_COST};
pub use utxo::{SelectionOptions, SelectionStrategy};
pub use wallet::{MultisigStatus, Wallet};
+3 -8
View File
@@ -4,8 +4,8 @@
//! matching the C++ wallet2::create_token() validation logic.
use crate::error::WalletError;
use salvium_types::constants::COIN;
use salvium_types::consensus::MONEY_SUPPLY;
use salvium_types::constants::COIN;
/// Cost in SAL1 atomic units to create a token (1000 SAL1).
pub const CREATE_TOKEN_COST: u64 = 1000 * COIN;
@@ -64,17 +64,12 @@ pub fn validate_create_token_params(
// Cannot start with "SAL"
if coin_symbol.starts_with("SAL") {
return Err(WalletError::Other(
"token symbol cannot start with 'SAL' (reserved)".into(),
));
return Err(WalletError::Other("token symbol cannot start with 'SAL' (reserved)".into()));
}
// Cannot be a reserved name
if RESERVED_NAMES.contains(&coin_symbol) {
return Err(WalletError::Other(format!(
"token symbol '{}' is reserved",
coin_symbol
)));
return Err(WalletError::Other(format!("token symbol '{}' is reserved", coin_symbol)));
}
// Supply must be at least 1
+12
View File
@@ -249,6 +249,18 @@ impl Wallet {
Ok(height)
}
/// Scan the mempool for pending transactions relevant to this wallet.
///
/// Can be called independently of `sync()` — useful when the wallet is
/// already at chain tip but new mempool transactions may have arrived.
#[cfg(not(target_arch = "wasm32"))]
pub async fn scan_mempool(
&self,
pool: &salvium_rpc::NodePool,
) -> Result<crate::pool_scan::PoolScanResult, WalletError> {
crate::pool_scan::scan_mempool(pool, self.db.clone(), &self.scan_context).await
}
// ── UTXO selection ───────────────────────────────────────────────────
/// Select unspent outputs for a transfer.
+114
View File
@@ -0,0 +1,114 @@
# Integration Testing
All integration tests are pure Rust (`#[ignore]`-gated). Run them with `--ignored`.
## Prerequisites
1. Testnet daemon running (default: `http://node12.whiskymine.io:29081`)
2. Wallet files at `~/testnet-wallet/`:
- `wallet-a.json` + `wallet-a.pin` (sender)
- `wallet-b.json` + `wallet-b.pin` (receiver)
3. Miner binary built:
```
cargo build -p salvium-miner --release
```
## Full Orchestrator (recommended)
Mines from genesis through all hard forks (HF1-HF10), testing transfers,
stakes, burns, and sweeps at each era boundary.
```bash
cargo test -p salvium-wallet --test full_testnet -- --ignored --nocapture
```
With a custom daemon:
```bash
TESTNET_DAEMON_URL=http://localhost:29081 \
cargo test -p salvium-wallet --test full_testnet -- --ignored --nocapture
```
Resume from a specific fork (e.g. after a partial run):
```bash
RESUME_FROM_HF=6 \
cargo test -p salvium-wallet --test full_testnet -- --ignored --nocapture
```
## Individual Test Suites
Run these against an already-synced testnet with funded wallets.
Order matters -- run top-to-bottom for best results.
```bash
# RPC connectivity
cargo test -p salvium-rpc --test testnet -- --ignored --nocapture
# Wallet sync
cargo test -p salvium-wallet --test testnet_sync -- --ignored --nocapture
# Transfers
cargo test -p salvium-wallet --test testnet_transfer -- --ignored --nocapture
# Stakes
cargo test -p salvium-wallet --test testnet_stake -- --ignored --nocapture
# Burns
cargo test -p salvium-wallet --test testnet_burn -- --ignored --nocapture
# Converts (HF255 gated -- expect rejection on current testnet)
cargo test -p salvium-wallet --test testnet_convert -- --ignored --nocapture
# Subaddresses
cargo test -p salvium-wallet --test testnet_subaddress -- --ignored --nocapture
# Decoy selection / ring building
cargo test -p salvium-tx --test testnet -- --ignored --nocapture
# RCT verification against testnet TXs
cargo test -p salvium-tx --test rct_verify_testnet -- --ignored --nocapture
# Consensus simulation
cargo test -p salvium-consensus --test testnet_sim -- --ignored --nocapture
# FFI integration (wallet open/sync/transfer via C ABI)
cargo test -p salvium-ffi --test testnet_ffi -- --ignored --nocapture
# FFI wallet sync
cargo test -p salvium-ffi --test wallet_sync -- --ignored --nocapture
# Run ALL ignored tests at once
cargo test --workspace -- --ignored --nocapture
```
## Testnet Hard Fork Schedule
| HF | Height | Key Changes |
|----|--------|-------------|
| 1 | 1 | Genesis (SAL, BulletproofPlus) |
| 2 | 250 | ENABLE_N_OUTS, 2021-scaling fees |
| 3 | 500 | Full proofs |
| 4 | 600 | Enforce full proofs |
| 5 | 800 | Shutdown user TXs |
| 6 | 815 | AUDIT1, SAL1, SalviumZero |
| 7 | 900 | AUDIT1 pause |
| 8 | 950 | AUDIT2 |
| 9 | 1000 | AUDIT2 pause |
| 10 | 1100 | CARROT (SalviumOne, carrot addresses) |
| 11 | 1200 | ENABLE_TOKENS (CREATE_TOKEN, ROLLUP, TX v5) |
## CLI Manual Testing
```bash
cargo run -p salvium-cli -- --network testnet --daemon http://node12.whiskymine.io:29081 sync
cargo run -p salvium-cli -- --network testnet balance
cargo run -p salvium-cli -- --network testnet transfer --address SLVx... --amount 1.0
cargo run -p salvium-cli -- --network testnet stake --amount 100.0
cargo run -p salvium-cli -- --network testnet create-token --ticker TEST --supply 100000000 --decimals 8
```