Scanner

Efficiently detects incoming stealth payments by checking announcements against your viewing key using ECDH operations.

Quick Overview

Module:onyx_sdk::scanner
Traits:Clone

How Scanning Works

For each announcement, the scanner performs an ECDH operation between your viewing key and the ephemeral public key. If the resulting stealth address matches, the payment belongs to you.

Definition

struct
rust
pub struct Scanner {
    /// The viewing key used to detect payments
    viewing_key: SecretKey,
    /// The spending public key for address derivation
    spending_pubkey: PublicKey,
    /// Optional timestamp filter
    since: Option<i64>,
    /// Whether this scanner can derive spending keys
    can_spend: bool,
}

Constructor Methods

new
pub fn new(meta: &StealthMetaAddress) -> Self

Creates a scanner with full capabilities (scan + spend) from a complete meta-address.

use onyx_sdk::prelude::*;

let meta = StealthMetaAddress::load_from_file("~/.onyx/keys.json")?;
let scanner = Scanner::new(&meta);

// This scanner can detect payments AND derive spending keys
let payments = scanner.scan(&announcements)?;

for payment in &payments {
    let keypair = StealthKeypair::from_payment(&meta, payment)?;
    // Can sign transactions
}
new_view_only
pub fn new_view_only(view_only: &ViewOnlyMetaAddress) -> Self

Creates a view-only scanner that can detect payments but cannot derive spending keys.

use onyx_sdk::prelude::*;

// Load view-only credentials (e.g., for a watch service)
let view_only = ViewOnlyMetaAddress::load_from_file("~/.onyx/view-only.json")?;
let scanner = Scanner::new_view_only(&view_only);

// Can detect payments
let payments = scanner.scan(&announcements)?;

// But CANNOT derive spending keys
// StealthKeypair::from_payment(&view_only, &payment) -> Error!

Use Case: View-only scanners are ideal for notification services, portfolio trackers, or any system that needs to detect payments without the ability to spend.

Builder Methods

since
pub fn since(self, timestamp: i64) -> Self

Filters announcements to only check those created after the given Unix timestamp.

use std::time::{SystemTime, UNIX_EPOCH};

let now = SystemTime::now()
    .duration_since(UNIX_EPOCH)?
    .as_secs() as i64;

// Only scan announcements from the last hour
let one_hour_ago = now - 3600;

let payments = Scanner::new(&meta)
    .since(one_hour_ago)
    .scan(&announcements)?;

println!("Found {} payments in the last hour", payments.len());
since_duration
pub fn since_duration(self, duration: Duration) -> Self

Convenience method to filter by a duration before now.

use std::time::Duration;

// Scan last 24 hours
let payments = Scanner::new(&meta)
    .since_duration(Duration::from_secs(86400))
    .scan(&announcements)?;

// Scan last 7 days
let weekly_payments = Scanner::new(&meta)
    .since_duration(Duration::from_secs(7 * 86400))
    .scan(&announcements)?;

Core Methods

scan
pub fn scan(&self, announcements: &[Announcement]) -> Result<Vec<StealthPayment>>

Scans a slice of announcements and returns all payments addressed to this meta-address.

let meta = StealthMetaAddress::load_from_file("~/.onyx/keys.json")?;
let scanner = Scanner::new(&meta);

// Fetch announcements from the on-chain registry
let announcements = fetch_announcements(&rpc_client)?;

// Scan for payments
let payments = scanner.scan(&announcements)?;

println!("Found {} payments", payments.len());

for payment in &payments {
    println!("  {} SOL at {}", payment.sol_amount(), payment.stealth_address);
}
scan_single
pub fn scan_single(&self, announcement: &Announcement) -> Option<StealthPayment>

Checks a single announcement. Returns Some(payment) if it belongs to you, None otherwise.

// Useful for real-time monitoring
for announcement in new_announcements {
    if let Some(payment) = scanner.scan_single(&announcement) {
        println!("New payment detected: {} SOL", payment.sol_amount());
        notify_user(&payment)?;
    }
}
check_address
pub fn check_address(&self, stealth_address: &Pubkey, ephemeral_pubkey: &[u8; 32]) -> bool

Checks if a stealth address belongs to this meta-address without fetching balance.

// Quick ownership check
let is_mine = scanner.check_address(
    &stealth_address,
    &ephemeral_pubkey,
);

if is_mine {
    println!("This address belongs to me!");
}

Announcement Type

The scanner works with Announcement structs from the on-chain registry:

announcement.rs
rust
/// On-chain payment announcement
pub struct Announcement {
    /// Sender's one-time ephemeral public key
    pub ephemeral_pubkey: [u8; 32],
    /// The stealth address where funds were sent
    pub stealth_address: Pubkey,
    /// When the announcement was created
    pub timestamp: i64,
    /// Optional: Token mint for SPL token payments
    pub token_mint: Option<Pubkey>,
}

Fetching Announcements

Use the registry client to fetch announcements from the Onyx Anchor program:

fetch_announcements.rs
rust
use onyx_sdk::prelude::*;

fn fetch_announcements(rpc: &RpcClient) -> Result<Vec<Announcement>> {
    // Get the registry program
    let registry = OnyxRegistry::new(rpc);

    // Fetch all announcements
    let all = registry.fetch_all()?;

    // Or fetch with pagination
    let page = registry.fetch_page(0, 100)?;

    // Or fetch since a timestamp
    let recent = registry.fetch_since(last_scan_time)?;

    Ok(recent)
}

// Example with filtering
let mut scanner = Scanner::new(&meta);
let since = last_scan_timestamp();

let announcements = registry.fetch_since(since)?;
let payments = scanner.scan(&announcements)?;

// Update last scan timestamp
save_scan_timestamp(SystemTime::now())?;

Performance Characteristics

Scanning is computationally efficient but scales linearly with announcement count:

AnnouncementsTimeMemory
1,000< 100ms~100 KB
10,000< 1s~1 MB
100,000~5-10s~10 MB
1,000,000~1 min~100 MB

Best Practices

Use Incremental Scanning

Store the timestamp of your last scan and only check new announcements. This keeps scan time constant.

let since = load_last_scan_time()?;
let payments = scanner.since(since).scan(&announcements)?;
save_last_scan_time(now())?;

Batch RPC Calls

Fetch announcements in batches to reduce network overhead.

let batch_size = 1000;
for offset in (0..).step_by(batch_size) {
    let batch = registry.fetch_page(offset, batch_size)?;
    if batch.is_empty() { break; }
    let payments = scanner.scan(&batch)?;
    process_payments(payments)?;
}

Handle Reorgs

For production systems, scan with some overlap to handle chain reorganizations.

// Scan with 10-block safety margin
let safe_since = last_scan_time - 10 * SLOT_DURATION;
let payments = scanner.since(safe_since).scan(&announcements)?;
// Deduplicate against already-processed payments

Complete Example

scanner_example.rs
rust
use onyx_sdk::prelude::*;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

fn main() -> Result<()> {
    // Setup
    let meta = StealthMetaAddress::load_from_file("~/.onyx/keys.json")?;
    let rpc = RpcClient::new("https://api.mainnet-beta.solana.com");
    let registry = OnyxRegistry::new(&rpc);

    // Create scanner
    let scanner = Scanner::new(&meta);

    // Load last scan time (or use 0 for first scan)
    let last_scan = load_last_scan_time().unwrap_or(0);

    // Fetch new announcements
    let announcements = registry.fetch_since(last_scan)?;
    println!("Checking {} new announcements...", announcements.len());

    // Scan for payments
    let payments = scanner.scan(&announcements)?;

    if payments.is_empty() {
        println!("No new payments found.");
    } else {
        println!("Found {} payments:", payments.len());

        let mut total_sol = 0.0;
        for payment in &payments {
            println!("  {} SOL from {}",
                payment.sol_amount(),
                payment.stealth_address
            );
            total_sol += payment.sol_amount();
        }
        println!("Total: {} SOL", total_sol);
    }

    // Save scan time for next run
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)?
        .as_secs() as i64;
    save_last_scan_time(now)?;

    Ok(())
}