diff --git a/crates/salvium-cli/src/commands/daemon.rs b/crates/salvium-cli/src/commands/daemon.rs index 99dd399..f4e08da 100644 --- a/crates/salvium-cli/src/commands/daemon.rs +++ b/crates/salvium-cli/src/commands/daemon.rs @@ -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(()); } diff --git a/crates/salvium-cli/src/commands/history.rs b/crates/salvium-cli/src/commands/history.rs index a76990e..8968e29 100644 --- a/crates/salvium-cli/src/commands/history.rs +++ b/crates/salvium-cli/src/commands/history.rs @@ -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, diff --git a/crates/salvium-cli/src/commands/mod.rs b/crates/salvium-cli/src/commands/mod.rs index d8bd108..92098ae 100644 --- a/crates/salvium-cli/src/commands/mod.rs +++ b/crates/salvium-cli/src/commands/mod.rs @@ -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", } } diff --git a/crates/salvium-cli/src/commands/transfers.rs b/crates/salvium-cli/src/commands/transfers.rs index 8f95017..95602a2 100644 --- a/crates/salvium-cli/src/commands/transfers.rs +++ b/crates/salvium-cli/src/commands/transfers.rs @@ -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(()) +} diff --git a/crates/salvium-cli/src/main.rs b/crates/salvium-cli/src/main.rs index cfe08ec..169a347 100644 --- a/crates/salvium-cli/src/main.rs +++ b/crates/salvium-cli/src/main.rs @@ -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 } diff --git a/crates/salvium-consensus/src/validation.rs b/crates/salvium-consensus/src/validation.rs index c1bd160..dd52b9e 100644 --- a/crates/salvium-consensus/src/validation.rs +++ b/crates/salvium-consensus/src/validation.rs @@ -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 diff --git a/crates/salvium-ffi/src/transfer.rs b/crates/salvium-ffi/src/transfer.rs index 9646fc3..c7944fe 100644 --- a/crates/salvium-ffi/src/transfer.rs +++ b/crates/salvium-ffi/src/transfer.rs @@ -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, #[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::(wallet) }?; + let dh = unsafe { borrow_handle::(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( + ¶ms.ticker, + params.supply, + params.decimals, + ¶ms.metadata, + ) + .map_err(|e| e.to_string())?; + + let priority = parse_priority(¶ms.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, ¶ms, 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 { + 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 { + 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, ) -> Result { 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()); } diff --git a/crates/salvium-rpc/src/wallet_rpc.rs b/crates/salvium-rpc/src/wallet_rpc.rs index b62c507..704c953 100644 --- a/crates/salvium-rpc/src/wallet_rpc.rs +++ b/crates/salvium-rpc/src/wallet_rpc.rs @@ -469,6 +469,27 @@ pub struct SignMultisigResult { pub tx_hash_list: Vec, } +// ============================================================================= +// 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 { + 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 { let val = self.client.call("relay_tx", serde_json::json!({ "hex": hex })).await?; diff --git a/crates/salvium-tx/src/analysis.rs b/crates/salvium-tx/src/analysis.rs index 1a2e4b0..daadd87 100644 --- a/crates/salvium-tx/src/analysis.rs +++ b/crates/salvium-tx/src/analysis.rs @@ -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, }; diff --git a/crates/salvium-tx/src/builder.rs b/crates/salvium-tx/src/builder.rs index 6f9343b..6968f88 100644 --- a/crates/salvium-tx/src/builder.rs +++ b/crates/salvium-tx/src/builder.rs @@ -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, + rollup_binding_tag: Option, + layer2_rollup_data: Option, } 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 { diff --git a/crates/salvium-tx/src/lib.rs b/crates/salvium-tx/src/lib.rs index 9c577f4..ba9a412 100644 --- a/crates/salvium-tx/src/lib.rs +++ b/crates/salvium-tx/src/lib.rs @@ -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; diff --git a/crates/salvium-tx/src/sign.rs b/crates/salvium-tx/src/sign.rs index 9244570..e376cf6 100644 --- a/crates/salvium-tx/src/sign.rs +++ b/crates/salvium-tx/src/sign.rs @@ -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], diff --git a/crates/salvium-tx/src/types.rs b/crates/salvium-tx/src/types.rs index 0318156..22acb96 100644 --- a/crates/salvium-tx/src/types.rs +++ b/crates/salvium-tx/src/types.rs @@ -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) +/// 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, +} + // ─── 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, + /// Token metadata — CREATE_TOKEN transactions only (v5). + pub token_metadata: Option, + /// Layer 2 rollup data — ROLLUP transactions only (v5). + pub layer2_rollup_data: Option, } /// 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(); diff --git a/crates/salvium-tx/tests/testnet.rs b/crates/salvium-tx/tests/testnet.rs index 36276ac..464cbf0 100644 --- a/crates/salvium-tx/tests/testnet.rs +++ b/crates/salvium-tx/tests/testnet.rs @@ -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], diff --git a/crates/salvium-types/src/consensus.rs b/crates/salvium-types/src/consensus.rs index b666afe..5ec9417 100644 --- a/crates/salvium-types/src/consensus.rs +++ b/crates/salvium-types/src/consensus.rs @@ -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. diff --git a/crates/salvium-types/src/constants.rs b/crates/salvium-types/src/constants.rs index 9e70621..ab5e4a3 100644 --- a/crates/salvium-types/src/constants.rs +++ b/crates/salvium-types/src/constants.rs @@ -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()); } } diff --git a/crates/salvium-types/src/lib.rs b/crates/salvium-types/src/lib.rs index fc7e81c..5c87b2e 100644 --- a/crates/salvium-types/src/lib.rs +++ b/crates/salvium-types/src/lib.rs @@ -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, +}; diff --git a/crates/salvium-wallet/src/lib.rs b/crates/salvium-wallet/src/lib.rs index c88590b..87fb9e0 100644 --- a/crates/salvium-wallet/src/lib.rs +++ b/crates/salvium-wallet/src/lib.rs @@ -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}; diff --git a/crates/salvium-wallet/src/token.rs b/crates/salvium-wallet/src/token.rs index 93bfe83..cb36eb5 100644 --- a/crates/salvium-wallet/src/token.rs +++ b/crates/salvium-wallet/src/token.rs @@ -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 diff --git a/crates/salvium-wallet/src/wallet.rs b/crates/salvium-wallet/src/wallet.rs index 9f9e0ec..af6737a 100644 --- a/crates/salvium-wallet/src/wallet.rs +++ b/crates/salvium-wallet/src/wallet.rs @@ -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::scan_mempool(pool, self.db.clone(), &self.scan_context).await + } + // ── UTXO selection ─────────────────────────────────────────────────── /// Select unspent outputs for a transfer. diff --git a/docs/integration-testing.md b/docs/integration-testing.md new file mode 100644 index 0000000..dddf1e9 --- /dev/null +++ b/docs/integration-testing.md @@ -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 +```