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:
@@ -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(());
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
¶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<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());
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user