Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions integration-tests/features/scanning.feature
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,17 @@ Feature: Blockchain Scanning
And I perform a scan with batch size "5"
Then blocks should be fetched in batches of "5"
And the scan should complete successfully

Scenario: Fast sync with small safety buffer
Given I have a seed node MinerNode
And I have a test database with an existing wallet
When I mine 10 blocks on MinerNode
And I perform a fast sync with safety buffer "5"
Then the fast sync should complete successfully

Scenario: Fast sync completes successfully
Given I have a seed node MinerNode
And I have a test database with an existing wallet
When I mine 10 blocks on MinerNode
And I perform a fast sync
Then the fast sync should complete successfully
57 changes: 57 additions & 0 deletions integration-tests/steps/scanning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,60 @@ async fn blocks_in_batches(world: &mut MinotariWorld, _batch_size: String) {
// Verify scan with custom batch size completed
scan_succeeds(world).await;
}

#[when(regex = r#"^I perform a fast sync with safety buffer "([^"]*)"$"#)]
async fn fast_sync_with_safety_buffer(world: &mut MinotariWorld, safety_buffer: String) {
let db_path = world.database_path.as_ref().expect("Database not set up");

// Get base node URL from the first available base node
let base_url = if let Some((_, node)) = world.base_nodes.iter().next() {
format!("http://127.0.0.1:{}", node.http_port)
} else {
panic!("No base node available for scanning");
};

let (cmd, mut args) = world.get_minotari_command();
args.extend_from_slice(&[
"scan".to_string(),
"--database-path".to_string(),
db_path.to_str().unwrap().to_string(),
"--password".to_string(),
world.test_password.clone(),
"--base-url".to_string(),
base_url,
"--fast-sync".to_string(),
"--fast-sync-safety-buffer".to_string(),
safety_buffer,
]);

let output = Command::new(&cmd)
.args(&args)
.output()
.expect("Failed to execute fast sync command");

world.last_command_exit_code = Some(output.status.code().unwrap_or(-1));
world.last_command_output = Some(String::from_utf8_lossy(&output.stdout).to_string());
world.last_command_error = Some(String::from_utf8_lossy(&output.stderr).to_string());

println!("Fast sync output: {}", world.last_command_output.as_ref().unwrap());
if !world.last_command_error.as_ref().unwrap().is_empty() {
println!("Fast sync stderr: {}", world.last_command_error.as_ref().unwrap());
}
}

#[when("I perform a fast sync")]
async fn fast_sync_default(world: &mut MinotariWorld) {
// Use a small safety buffer (5 blocks) in tests so fast sync completes quickly
// even when only a few blocks have been mined. The production default is 720 blocks.
fast_sync_with_safety_buffer(world, "5".to_string()).await;
}

#[then("the fast sync should complete successfully")]
async fn fast_sync_succeeds(world: &mut MinotariWorld) {
assert_eq!(
world.last_command_exit_code,
Some(0),
"Fast sync command failed: {}",
world.last_command_error.as_deref().unwrap_or("")
);
}
4 changes: 4 additions & 0 deletions minotari/config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ scan_interval_secs = 60
api_port = 9000
confirmation_window = 3
account_name = "default"
# Safety buffer (in blocks) used when running fast sync.
# fast_sync_target_height = tip - fast_sync_safety_buffer
# Defaults to 720 blocks (~12 hours on mainnet) if not set.
# fast_sync_safety_buffer = 720

# [wallet.webhook]
# url = "https://your-api.com/webhook"
Expand Down
24 changes: 24 additions & 0 deletions minotari/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ pub enum Commands {
///
/// - `max_blocks_to_scan`: Limits scan duration (default: 50)
/// - `batch_size`: Number of blocks per API request (default: 100)
///
/// # Fast Sync
///
/// Use `--fast-sync` to run a three-phase fast synchronisation:
/// 1. Scans birthday → `tip - safety_buffer` for the unspent UTXO set
/// 2. Scans `tip - safety_buffer` → tip for recent changes
/// 3. Rescans birthday → tip to fill in the complete transaction history
Scan {
#[command(flatten)]
security: SecurityArgs,
Expand All @@ -164,6 +171,23 @@ pub enum Commands {
/// Maximum number of blocks to scan in this invocation.
#[arg(short = 'n', long, default_value_t = 50)]
max_blocks_to_scan: u64,

/// Enable fast synchronisation mode.
///
/// When set, the scanner runs three phases to quickly establish the current
/// wallet balance before filling in the full transaction history:
/// 1. birthday → tip-safety_buffer (unspent UTXO set)
/// 2. tip-safety_buffer → tip (recent full scan)
/// 3. birthday → tip (full history)
#[arg(long, default_value_t = false)]
fast_sync: bool,

/// Safety buffer (in blocks) used when calculating the fast-sync target height.
///
/// `fast_sync_target_height = tip - fast_sync_safety_buffer`.
/// Only used when `--fast-sync` is set. Defaults to 720 blocks.
#[arg(long)]
fast_sync_safety_buffer: Option<u64>,
},

/// Re-scan the blockchain from a specific height.
Expand Down
6 changes: 6 additions & 0 deletions minotari/src/config/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ pub struct WalletConfig {
pub confirmation_window: u64,
pub account_name: Option<String>,
pub webhook: WebhookConfig,
/// Safety buffer (in blocks) for fast sync.
///
/// The fast-sync target height is `tip - fast_sync_safety_buffer`.
/// If not set, the default value of 720 blocks (approximately 12 hours on mainnet) is used.
pub fast_sync_safety_buffer: Option<u64>,
}

impl Default for WalletConfig {
Expand All @@ -39,6 +44,7 @@ impl Default for WalletConfig {
confirmation_window: 3,
account_name: None,
webhook: WebhookConfig::default(),
fast_sync_safety_buffer: None,
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion minotari/src/db/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ pub fn get_accounts(conn: &Connection, friendly_name: Option<&str>) -> WalletDbR
}
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountRow {
pub id: i64,
pub friendly_name: String,
Expand Down
55 changes: 48 additions & 7 deletions minotari/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,20 +288,31 @@ async fn main() -> Result<(), anyhow::Error> {
db,
account,
max_blocks_to_scan,
fast_sync,
fast_sync_safety_buffer,
} => {
info!("Scanning blockchain...");

wallet_config.apply_node(&node);
wallet_config.apply_database(&db);
wallet_config.apply_account(&account);

let (events, _more_blocks_to_scan) = scan(
&security.password,
&wallet_config,
max_blocks_to_scan,
wallet_config.account_name.as_deref(),
)
.await?;
let (events, _more_blocks_to_scan) = if fast_sync {
let safety_buffer = fast_sync_safety_buffer
.or(wallet_config.fast_sync_safety_buffer)
.unwrap_or(scan::DEFAULT_FAST_SYNC_SAFETY_BUFFER);
info!(safety_buffer = safety_buffer; "Fast sync enabled");
fast_sync_scan(&security.password, &wallet_config, safety_buffer, wallet_config.account_name.as_deref())
.await?
} else {
scan(
&security.password,
&wallet_config,
max_blocks_to_scan,
wallet_config.account_name.as_deref(),
)
.await?
};
info!(event_count = events.len(); "Scan complete");
Ok(())
},
Expand Down Expand Up @@ -596,6 +607,36 @@ async fn scan(
scanner.run().await
}

async fn fast_sync_scan(
password: &str,
config: &WalletConfig,
safety_buffer: u64,
account_name: Option<&str>,
) -> Result<(Vec<WalletEvent>, bool), ScanError> {
let mut scanner = scan::Scanner::new(
password,
&config.base_url,
config.database_path.clone(),
config.batch_size,
config.confirmation_window,
)
.mode(scan::ScanMode::FastSync { safety_buffer });

if let Some(name) = account_name {
scanner = scanner.account(name);
}

if let Some(url) = &config.webhook.url {
let trigger_config = WebhookTriggerConfig {
url: url.clone(),
send_only_event_types: config.webhook.send_only_event_types.clone(),
};
scanner = scanner.webhook_config(trigger_config);
}

scanner.run().await
}

async fn rescan(
password: &str,
config: &WalletConfig,
Expand Down
46 changes: 46 additions & 0 deletions minotari/src/scan/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ pub const MAX_BACKOFF_SECONDS: u64 = 60;

pub const OPTIMAL_SCANNING_THREADS: usize = 0; // Based on num_cpus

/// Default safety buffer (in blocks) for fast sync.
///
/// The fast sync target height is calculated as `tip - DEFAULT_FAST_SYNC_SAFETY_BUFFER`.
/// This buffer ensures we have a stable UTXO set snapshot that is unlikely to be affected
/// by chain reorganisations during the fast scan phase.
pub const DEFAULT_FAST_SYNC_SAFETY_BUFFER: u64 = 720;

/// Configuration for scan operation timeouts.
///
/// This is a simplified configuration struct for controlling timeout behavior.
Expand Down Expand Up @@ -74,6 +81,11 @@ impl Default for ScanTimeoutConfig {
/// let continuous = ScanMode::Continuous {
/// poll_interval: Duration::from_secs(30),
/// };
///
/// // Fast sync with default safety buffer
/// let fast_sync = ScanMode::FastSync {
/// safety_buffer: DEFAULT_FAST_SYNC_SAFETY_BUFFER,
/// };
/// ```
#[derive(Debug, Clone)]
pub enum ScanMode {
Expand Down Expand Up @@ -101,6 +113,40 @@ pub enum ScanMode {
/// Duration to wait between scan cycles after reaching chain tip.
poll_interval: Duration,
},

/// Fast synchronisation that prioritises getting an accurate current balance
/// quickly before filling in the full transaction history.
///
/// The fast sync process runs three sequential phases:
///
/// 1. **Phase 1 – Unspent UTXO sync** (birthday → `tip - safety_buffer`):
/// Scans from the wallet birthday up to the *fast-sync target height*
/// (`tip - safety_buffer`), retrieving the unspent UTXO set at that
/// height. This phase rapidly establishes an accurate picture of
/// outputs that belong to the wallet without scanning the most recent
/// (and most volatile) blocks.
///
/// 2. **Phase 2 – Recent full scan** (`fast_sync_target_height` → tip):
/// Performs a complete scan of the remaining, recent blocks up to the
/// chain tip. After this phase the wallet balance is fully accurate.
///
/// 3. **Phase 3 – Full history scan** (birthday → tip):
/// Re-scans the entire range from the wallet birthday to the tip to
/// build complete transaction history (including spending records for
/// outputs that may have been spent within the Phase 1 range).
///
/// # Safety Buffer
///
/// The `safety_buffer` defines how many blocks from the tip to treat as
/// "recent". A larger buffer means Phase 1 covers a smaller range and
/// Phase 2 covers a larger range. The default is [`DEFAULT_FAST_SYNC_SAFETY_BUFFER`]
/// (720 blocks, approximately 12 hours on mainnet).
FastSync {
/// Number of blocks from the tip that are treated as the "recent" zone.
///
/// `fast_sync_target_height = tip_height - safety_buffer`.
safety_buffer: u64,
},
}

/// Comprehensive configuration for scan retry behavior.
Expand Down
Loading