Skip to content
Open
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
15 changes: 15 additions & 0 deletions src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ use emily_client::apis::configuration::Configuration as EmilyConfig;
use crate::bitcoin::node::BitcoinCoreClient;
use crate::config::Settings;
use crate::error::Error;
use crate::storage::memory::{SharedStore, Store};

/// Application context
#[derive(Clone)]
pub struct Context {
bitcoin_client: BitcoinCoreClient,
emily_config: Arc<EmilyConfig>,
storage: SharedStore,
settings: Arc<Settings>,
}

impl TryFrom<&Settings> for Context {
Expand All @@ -32,6 +35,8 @@ impl TryFrom<&Settings> for Context {
Ok(Self {
bitcoin_client,
emily_config: Arc::new(emily_config),
storage: Store::new_shared(),
settings: Arc::new(value.clone()),
})
}
}
Expand All @@ -46,4 +51,14 @@ impl Context {
pub fn emily_config(&self) -> &EmilyConfig {
&self.emily_config
}

/// Get a reference to the storage
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says this returns “a reference to the storage”, but the function returns a cloned SharedStore handle (Arc<Mutex<...>>). Consider rewording to avoid implying a borrowed reference (e.g., “Get a handle to the shared storage”).

Suggested change
/// Get a reference to the storage
/// Get a handle to the shared storage

Copilot uses AI. Check for mistakes.
pub fn storage(&self) -> SharedStore {
self.storage.clone()
}

/// Get a reference to the config
pub fn settings(&self) -> &Settings {
&self.settings
}
}
72 changes: 13 additions & 59 deletions src/deposit_monitor.rs
Original file line number Diff line number Diff line change
@@ -1,64 +1,20 @@
//! Module to monitor for pending deposits

use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::str::FromStr as _;

use bitcoin::{BlockHash, ScriptBuf, Txid};
use bitcoin::{BlockHash, Txid};
use emily_client::models::CreateDepositRequestBody;
use lru::LruCache;
use sbtc::deposits::{DepositScriptInputs, ReclaimScriptInputs};

use crate::bitcoin::{BlockRef, Utxo};
use crate::config::MonitoredDepositConfig;
use crate::context::Context;
use crate::error::Error;

/// A deposit address to monitor
#[derive(Debug, Clone)]
pub struct MonitoredDeposit {
/// Monitored deposit alias
pub alias: String,
/// Deposit script inputs
pub deposit_script_inputs: DepositScriptInputs,
/// Reclaim script inputs
pub reclaim_script_inputs: ReclaimScriptInputs,
}

impl MonitoredDeposit {
/// Get the scriptPubKey for this deposit address
pub fn to_script_pubkey(&self) -> ScriptBuf {
sbtc::deposits::to_script_pubkey(
self.deposit_script_inputs.deposit_script(),
self.reclaim_script_inputs.reclaim_script(),
)
}
}

impl TryFrom<(&String, &MonitoredDepositConfig)> for MonitoredDeposit {
type Error = Error;

fn try_from((alias, deposit): (&String, &MonitoredDepositConfig)) -> Result<Self, Self::Error> {
let deposit = deposit.clone();
Ok(MonitoredDeposit {
alias: alias.clone(),
deposit_script_inputs: DepositScriptInputs {
signers_public_key: deposit.signers_xonly,
recipient: deposit.recipient,
max_fee: deposit.max_fee,
},
reclaim_script_inputs: ReclaimScriptInputs::try_new(
deposit.lock_time,
deposit.reclaim_script,
)?,
})
}
}
use crate::storage::Storage as _;

/// Deposit monitor
pub struct DepositMonitor {
context: Context,
monitored: HashMap<ScriptBuf, MonitoredDeposit>,
tx_hex_cache: LruCache<(Txid, BlockHash), String>,
created_deposits: LruCache<(Txid, u32), ()>,
}
Expand All @@ -75,15 +31,9 @@ const CREATED_DEPOSITS_CACHE_SIZE: NonZeroUsize =

impl DepositMonitor {
/// Creates a new `DepositMonitor`
pub fn new(context: Context, monitored: Vec<MonitoredDeposit>) -> Self {
let monitored = monitored
.into_iter()
.map(|m| (m.to_script_pubkey(), m))
.collect();

pub fn new(context: Context) -> Self {
Self {
context,
monitored,
tx_hex_cache: LruCache::new(TX_HEX_CACHE_SIZE),
created_deposits: LruCache::new(CREATED_DEPOSITS_CACHE_SIZE),
}
Expand All @@ -96,8 +46,9 @@ impl DepositMonitor {
chain_tip: &BlockRef,
) -> Result<CreateDepositRequestBody, Error> {
let monitored_deposit = self
.monitored
.get(&utxo.script_pub_key)
.context
.storage()
.get_by_script(&utxo.script_pub_key)?
.ok_or_else(|| Error::MissingMonitoredDeposit(utxo.script_pub_key.clone()))?;

let unlocking_time =
Expand Down Expand Up @@ -137,10 +88,13 @@ impl DepositMonitor {
&mut self,
chain_tip: &BlockRef,
) -> Result<Vec<CreateDepositRequestBody>, Error> {
let utxos = self
.context
.bitcoin_client()
.get_utxos(self.monitored.keys())?;
let script_pubkeys = self.context.storage().get_scripts()?;
if script_pubkeys.is_empty() {
return Ok(Vec::new());
}

// TODO: batch the get_utxos call
let utxos = self.context.bitcoin_client().get_utxos(&script_pubkeys)?;

let create_deposits = utxos
.iter()
Expand Down
4 changes: 4 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub enum Error {
#[error("could not parse the provided URL: {0}")]
InvalidUrl(#[source] url::ParseError),

/// Poisoned mutex
#[error("poisoned mutex")]
PoisonedMutex,

/// No chain tip found.
#[error("no bitcoin chain tip")]
NoChainTip,
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod deposit_monitor;
pub mod error;
pub mod logging;
pub mod stacks;
pub mod storage;

#[cfg(any(test, feature = "testing"))]
pub mod testing;
16 changes: 13 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ use emily_client::apis::deposit_api;
use spox::bitcoin::BlockRef;
use spox::config::Settings;
use spox::context::Context;
use spox::deposit_monitor::{DepositMonitor, MonitoredDeposit};
use spox::deposit_monitor::DepositMonitor;
use spox::error::Error;
use spox::stacks::node::StacksClient;
use spox::storage::Storage;
use spox::storage::model::{MonitoredDeposit, MonitoredDepositSource};

#[derive(Debug, Clone, Copy, ValueEnum)]
enum LogOutputFormat {
Expand Down Expand Up @@ -146,7 +148,10 @@ async fn get_deposit_address(
) -> Result<(), Box<dyn std::error::Error>> {
for deposit in monitored {
let address = Address::from_script(&deposit.to_script_pubkey(), args.network)?;
println!("{}: {}", deposit.alias, address);
match &deposit.source {
MonitoredDepositSource::Config(alias) => println!("{alias}: {address}"),
MonitoredDepositSource::Registry(id) => println!("id={id}: {address}"),
}
}
Ok(())
}
Expand Down Expand Up @@ -182,7 +187,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

let context = Context::try_from(&config)?;

let mut deposit_monitor = DepositMonitor::new(context.clone(), monitored);
let store = context.storage();
for monitored_deposit in monitored {
store.add(monitored_deposit)?;
}

let mut deposit_monitor = DepositMonitor::new(context.clone());

runloop(context, &mut deposit_monitor, config.polling_interval).await;

Expand Down
71 changes: 71 additions & 0 deletions src/storage/memory.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! In-memory implementation of the Storage trait

use std::{
collections::HashMap,
sync::{Arc, Mutex},
};

use bitcoin::ScriptBuf;

use crate::{
error::Error,
storage::{Storage, model::MonitoredDeposit},
};

/// A store wrapped in an Arc<Mutex<...>> for interior mutability
pub type SharedStore = Arc<Mutex<Store>>;

/// In-memory store
#[derive(Debug, Clone, Default)]
pub struct Store {
last_next_address_id: u64,
monitored: HashMap<ScriptBuf, MonitoredDeposit>,
}
Comment on lines +19 to +23
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Store derives Clone, which will deep-clone the full HashMap<ScriptBuf, MonitoredDeposit> if it’s ever used, potentially causing unintended copies and extra allocations. Since callers should clone SharedStore (the Arc) instead, consider removing Clone from Store to prevent accidental expensive clones.

Copilot uses AI. Check for mistakes.

impl Store {
/// Create an empty store wrapped in an Arc<Mutex<...>>
pub fn new_shared() -> SharedStore {
Arc::new(Mutex::new(Self::default()))
}
}

/// Storage trait implementation for the in-memory store
impl Storage for SharedStore {
/// Add a monitored deposit. If the script pubkey already exists it's a nop.
fn add(&self, monitored_deposit: MonitoredDeposit) -> Result<(), Error> {
let mut store = self.lock().map_err(|_| Error::PoisonedMutex)?;

let key = monitored_deposit.to_script_pubkey();
store.monitored.entry(key).or_insert(monitored_deposit);
Ok(())
Comment on lines +33 to +40
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new in-memory Storage implementation introduces core behavior (deduping in add, correctness of get_scripts, and tracking last_next_address_id) but there are no unit tests covering it. Adding tests would help catch regressions as more storage backends and registry integration are introduced.

Copilot uses AI. Check for mistakes.
}

/// Get a monitored deposit by script pubkey.
fn get_by_script(&self, script: &ScriptBuf) -> Result<Option<MonitoredDeposit>, Error> {
let store = self.lock().map_err(|_| Error::PoisonedMutex)?;

Ok(store.monitored.get(script).cloned())
}

/// Get the script pubkeys for monitored deposits
fn get_scripts(&self) -> Result<Vec<ScriptBuf>, Error> {
let store = self.lock().map_err(|_| Error::PoisonedMutex)?;

Ok(store.monitored.keys().cloned().collect())
}

/// Get the last next-address-id from the registry
fn get_last_next_address_id(&self) -> Result<u64, Error> {
let store = self.lock().map_err(|_| Error::PoisonedMutex)?;

Ok(store.last_next_address_id)
}

/// Set the last next-address-id from the registry
fn set_last_next_address_id(&self, next_address_id: u64) -> Result<(), Error> {
let mut store = self.lock().map_err(|_| Error::PoisonedMutex)?;

store.last_next_address_id = next_address_id;
Ok(())
}
}
26 changes: 26 additions & 0 deletions src/storage/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//! Contains functionality for interacting with the storage abstraction

use bitcoin::ScriptBuf;

use crate::error::Error;

pub mod memory;
pub mod model;

/// Interface for spox storage.
pub trait Storage {
/// Add a monitored deposit. If the script pubkey already exists it's a nop.
fn add(&self, monitored_deposit: model::MonitoredDeposit) -> Result<(), Error>;

/// Get a monitored deposit by script pubkey.
fn get_by_script(&self, script: &ScriptBuf) -> Result<Option<model::MonitoredDeposit>, Error>;

/// Get the script pubkeys for monitored deposits
fn get_scripts(&self) -> Result<Vec<ScriptBuf>, Error>;

/// Get the last next-address-id from the registry
fn get_last_next_address_id(&self) -> Result<u64, Error>;

/// Set the last next-address-id from the registry
fn set_last_next_address_id(&self, next_address_id: u64) -> Result<(), Error>;
}
56 changes: 56 additions & 0 deletions src/storage/model.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//! Data models used in spox.

use bitcoin::ScriptBuf;
use sbtc::deposits::{DepositScriptInputs, ReclaimScriptInputs};

use crate::{config::MonitoredDepositConfig, error::Error};

/// The source for a deposit
#[derive(Debug, Clone)]
pub enum MonitoredDepositSource {
/// Address is configured in the config file (with this alias)
Config(String),
/// Address is registered on the smart contract registry (with this ID)
Registry(u64),
}

/// A deposit address to monitor
#[derive(Debug, Clone)]
pub struct MonitoredDeposit {
/// Monitored deposit source
pub source: MonitoredDepositSource,
/// Deposit script inputs
pub deposit_script_inputs: DepositScriptInputs,
/// Reclaim script inputs
pub reclaim_script_inputs: ReclaimScriptInputs,
}

impl MonitoredDeposit {
/// Get the scriptPubKey for this deposit address
pub fn to_script_pubkey(&self) -> ScriptBuf {
sbtc::deposits::to_script_pubkey(
self.deposit_script_inputs.deposit_script(),
self.reclaim_script_inputs.reclaim_script(),
)
}
}

impl TryFrom<(&String, &MonitoredDepositConfig)> for MonitoredDeposit {
type Error = Error;

fn try_from((alias, deposit): (&String, &MonitoredDepositConfig)) -> Result<Self, Self::Error> {
let deposit = deposit.clone();
Ok(MonitoredDeposit {
source: MonitoredDepositSource::Config(alias.clone()),
deposit_script_inputs: DepositScriptInputs {
signers_public_key: deposit.signers_xonly,
recipient: deposit.recipient,
max_fee: deposit.max_fee,
},
reclaim_script_inputs: ReclaimScriptInputs::try_new(
deposit.lock_time,
deposit.reclaim_script,
)?,
})
}
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MonitoredDeposit::try_from contains non-trivial conversion logic (building DepositScriptInputs and validating ReclaimScriptInputs). Consider adding unit tests for this conversion (including invalid reclaim scripts / locktimes) so config parsing failures surface in a controlled way.

Suggested change
}
}
#[cfg(test)]
mod tests {
use super::*;
use bitcoin::opcodes::all::OP_TRUE;
use bitcoin::script::Builder;
use bitcoin::XOnlyPublicKey;
use clarity::vm::types::PrincipalData;
fn monitored_deposit_config(lock_time: u32, reclaim_script: ScriptBuf) -> MonitoredDepositConfig {
MonitoredDepositConfig {
signers_xonly: XOnlyPublicKey::from_str(
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
)
.unwrap(),
recipient: PrincipalData::parse_standard_principal("ST000000000000000000002AMW42H")
.unwrap(),
max_fee: 1_000,
lock_time,
reclaim_script,
}
}
#[test]
fn try_from_builds_monitored_deposit_for_valid_config() {
let alias = "test-deposit".to_string();
let reclaim_script = Builder::new().push_opcode(OP_TRUE).into_script();
let config = monitored_deposit_config(500, reclaim_script.clone());
let monitored = MonitoredDeposit::try_from((&alias, &config)).unwrap();
match monitored.source {
MonitoredDepositSource::Config(source_alias) => assert_eq!(source_alias, alias),
MonitoredDepositSource::Registry(_) => panic!("expected config source"),
}
assert_eq!(
monitored.deposit_script_inputs.signers_public_key,
config.signers_xonly
);
assert_eq!(monitored.deposit_script_inputs.recipient, config.recipient);
assert_eq!(monitored.deposit_script_inputs.max_fee, config.max_fee);
assert_eq!(
monitored.reclaim_script_inputs.reclaim_script(),
reclaim_script.as_script()
);
}
#[test]
fn try_from_returns_error_for_invalid_reclaim_script() {
let alias = "test-deposit".to_string();
let config = monitored_deposit_config(500, ScriptBuf::new());
let result = MonitoredDeposit::try_from((&alias, &config));
assert!(result.is_err());
}
#[test]
fn try_from_returns_error_for_invalid_lock_time() {
let alias = "test-deposit".to_string();
let reclaim_script = Builder::new().push_opcode(OP_TRUE).into_script();
let config = monitored_deposit_config(0, reclaim_script);
let result = MonitoredDeposit::try_from((&alias, &config));
assert!(result.is_err());
}
}

Copilot uses AI. Check for mistakes.
3 changes: 2 additions & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ bootstrap create a donation for them.
```bash
# From sBTC repo
make devenv-up
cargo run -p signer --bin demo-cli donation --amount 10000
# Wait for devenv bootstrap, then fund the sBTC signers
cargo run -p signer --bin demo-cli donation
```

## Fund the stackers
Expand Down
4 changes: 2 additions & 2 deletions tests/solo/spox.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ signers_xonly = "000000000000000000000000000000000000000000000000000000000000000
recipient = "ST2FQWJMF9CGPW34ZWK8FEPNK072NEV1VKRNBBMJ9"
max_fee = 5000
lock_time = 100
reclaim_script = ""
reclaim_script = "7551" # OP_DROP OP_TRUE

[deposit.bob]
signers_xonly = "0000000000000000000000000000000000000000000000000000000000000001"
recipient = "ST2BMYXHQ63YP410C57808B1K9APN38KDQM9A0E6S"
max_fee = 2000
lock_time = 200
reclaim_script = ""
reclaim_script = "7500" # OP_DROP OP_FALSE
Loading