From 9347c2d4981920f350a57ea87828c28b1c2a7f22 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Fri, 10 Oct 2025 23:24:25 +0200 Subject: [PATCH 01/20] Update README with Subspaces development details --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 5981d2b..4ee4520 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,11 @@ Checkout [releases](https://github.com/spacesprotocol/spaces/releases) for an immediately usable binary version of this software. +## Work on Subspaces + +Spaces is live on mainnet. Subspaces is live on testnet4, and development work is happening on the [subspaces branch](https://github.com/spacesprotocol/spaces/tree/subspaces). + + ## What does it do? Spaces are sovereign Bitcoin identities. They leverage the existing infrastructure and security of Bitcoin without requiring a new blockchain or any modifications to Bitcoin itself [learn more](https://spacesprotocol.org). From 2a936686d187426efb6c90ccb6b7f35258d27ada Mon Sep 17 00:00:00 2001 From: Alex Tsokurov Date: Thu, 27 Nov 2025 18:45:59 +0100 Subject: [PATCH 02/20] set 0 height for recovered wallets --- client/src/rpc.rs | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 447a583..736a920 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -513,14 +513,13 @@ impl WalletManager { .map_err(|_| anyhow!("Mnemonic generation error"))?; let start_block = self.get_wallet_start_block(client).await?; - self.setup_new_wallet(name.to_string(), mnemonic.to_string(), start_block)?; + self.setup_new_wallet(name.to_string(), mnemonic.to_string(), Some(start_block.height))?; self.load_wallet(name).await?; Ok(mnemonic.to_string()) } - pub async fn recover_wallet(&self, client: &reqwest::Client, name: &str, mnemonic: &str) -> anyhow::Result<()> { - let start_block = self.get_wallet_start_block(client).await?; - self.setup_new_wallet(name.to_string(), mnemonic.to_string(), start_block)?; + pub async fn recover_wallet(&self, name: &str, mnemonic: &str) -> anyhow::Result<()> { + self.setup_new_wallet(name.to_string(), mnemonic.to_string(), None)?; self.load_wallet(name).await?; Ok(()) } @@ -529,14 +528,14 @@ impl WalletManager { &self, name: String, mnemonic: String, - start_block: BlockId, + start_block_height: Option, ) -> anyhow::Result<()> { let wallet_path = self.data_dir.join(&name); if wallet_path.exists() { return Err(anyhow!(format!("Wallet `{}` already exists", name))); } - let export = self.wallet_from_mnemonic(name.clone(), mnemonic, start_block)?; + let export = self.wallet_from_mnemonic(name.clone(), mnemonic, start_block_height)?; fs::create_dir_all(&wallet_path)?; let wallet_export_path = wallet_path.join("wallet.json"); let mut file = fs::File::create(wallet_export_path)?; @@ -548,7 +547,7 @@ impl WalletManager { &self, name: String, mnemonic: String, - start_block: BlockId, + start_block_height: Option, ) -> anyhow::Result { let (network, _) = self.fallback_network(); let xpriv = Self::descriptor_from_mnemonic(network, &mnemonic)?; @@ -558,7 +557,7 @@ impl WalletManager { .network(network) .create_wallet_no_persist()?; let export = - WalletExport::export_wallet(&tmp, &name, start_block.height).map_err(|e| anyhow!(e))?; + WalletExport::export_wallet(&tmp, &name, start_block_height.unwrap_or_default()).map_err(|e| anyhow!(e))?; Ok(export) } @@ -946,7 +945,7 @@ impl RpcServer for RpcServerImpl { async fn wallet_recover(&self, name: &str, mnemonic: String) -> Result<(), ErrorObjectOwned> { self.wallet_manager - .recover_wallet(&self.client, name, &mnemonic) + .recover_wallet(name, &mnemonic) .await .map_err(|error| { ErrorObjectOwned::owned(RPC_WALLET_NOT_LOADED, error.to_string(), None::) From 92b32afb7d53204c741657180ae6bd93374d061e Mon Sep 17 00:00:00 2001 From: Alex Tsokurov Date: Thu, 27 Nov 2025 19:01:23 +0100 Subject: [PATCH 03/20] use genesis block height for recovered wallets --- client/src/config.rs | 12 +++++++++++- client/src/rpc.rs | 8 +++++++- client/src/spaces.rs | 8 +------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/client/src/config.rs b/client/src/config.rs index 657cf18..f15ed42 100644 --- a/client/src/config.rs +++ b/client/src/config.rs @@ -15,7 +15,7 @@ use rand::{ {thread_rng, Rng}, }; use serde::Deserialize; -use spaces_protocol::bitcoin::Network; +use spaces_protocol::{bitcoin::Network, constants::ChainAnchor}; use crate::{ auth::{auth_token_from_cookie, auth_token_from_creds}, @@ -117,6 +117,16 @@ impl ExtendedNetwork { _ => Err(()), } } + + pub fn genesis(&self) -> ChainAnchor { + match self { + ExtendedNetwork::Testnet => ChainAnchor::TESTNET(), + ExtendedNetwork::Testnet4 => ChainAnchor::TESTNET4(), + ExtendedNetwork::Regtest => ChainAnchor::REGTEST(), + ExtendedNetwork::Mainnet => ChainAnchor::MAINNET(), + _ => panic!("unsupported network"), + } + } } impl Args { diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 736a920..a3be3fa 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -556,8 +556,14 @@ impl WalletManager { let tmp = bdk::Wallet::create(external, internal) .network(network) .create_wallet_no_persist()?; + + let start_block_height = match start_block_height { + Some(height) => height, + None => self.network.genesis().height, + }; + let export = - WalletExport::export_wallet(&tmp, &name, start_block_height.unwrap_or_default()).map_err(|e| anyhow!(e))?; + WalletExport::export_wallet(&tmp, &name, start_block_height).map_err(|e| anyhow!(e))?; Ok(export) } diff --git a/client/src/spaces.rs b/client/src/spaces.rs index ed0cfee..92b046d 100644 --- a/client/src/spaces.rs +++ b/client/src/spaces.rs @@ -257,12 +257,6 @@ impl Spaced { } pub fn genesis(network: ExtendedNetwork) -> ChainAnchor { - match network { - ExtendedNetwork::Testnet => ChainAnchor::TESTNET(), - ExtendedNetwork::Testnet4 => ChainAnchor::TESTNET4(), - ExtendedNetwork::Regtest => ChainAnchor::REGTEST(), - ExtendedNetwork::Mainnet => ChainAnchor::MAINNET(), - _ => panic!("unsupported network"), - } + network.genesis() } } From ab51cd157cce095e7f37e324d06dfbd72c40472f Mon Sep 17 00:00:00 2001 From: horologger Date: Sat, 13 Dec 2025 12:38:17 -0500 Subject: [PATCH 04/20] Fix panic when querying revoked spaces Fix data inconsistency in RevokeReason::Expired handling that caused panics when querying spaces that were revoked due to expiration. Root cause: Expired revocations only removed Outpoint->Spaceout mapping but left Space->Outpoint mapping, creating inconsistent state. Changes: - Remove Space->Outpoint mapping in Expired revocation handler - Handle inconsistencies gracefully in get_space_info by returning None and cleaning up orphaned Space->Outpoint mappings instead of panicking --- client/src/client.rs | 7 ++++++- client/src/store.rs | 17 +++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/client/src/client.rs b/client/src/client.rs index b056343..f1996ac 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -279,7 +279,12 @@ impl Client { // Space => Outpoint mapping will be removed // since this type of revocation only happens when an // expired space is being re-opened for auction. - // No bids here so only remove Outpoint -> Spaceout + // Remove both Space -> Outpoint and Outpoint -> Spaceout mappings + if let Some(space) = update.output.spaceout.space.as_ref() { + let base_hash = Sha256::hash(space.name.as_ref()); + let space_key = SpaceKey::from(base_hash); + state.remove(space_key); + } let hash = OutpointKey::from_outpoint::(update.output.outpoint()); state.remove(hash); diff --git a/client/src/store.rs b/client/src/store.rs index e87dc3d..95a5f63 100644 --- a/client/src/store.rs +++ b/client/src/store.rs @@ -218,10 +218,19 @@ impl ChainState for LiveSnapshot { if let Some(outpoint) = outpoint { let spaceout = self.get_spaceout(&outpoint)?; - return Ok(Some(FullSpaceOut { - txid: outpoint.txid, - spaceout: spaceout.expect("should exist if outpoint exists"), - })); + // Handle data inconsistency gracefully: if outpoint exists but spaceout doesn't, + // this indicates the space was revoked but the space->outpoint mapping wasn't cleaned up. + // Clean up the inconsistent mapping and return None instead of panicking. + if let Some(spaceout) = spaceout { + return Ok(Some(FullSpaceOut { + txid: outpoint.txid, + spaceout, + })); + } else { + // Clean up the inconsistent space->outpoint mapping + self.remove(*space_hash); + return Ok(None); + } } Ok(None) } From 5831c5010cf8fb3c67b812a0b76fffb582c4d3bd Mon Sep 17 00:00:00 2001 From: horologger Date: Sat, 13 Dec 2025 12:48:22 -0500 Subject: [PATCH 05/20] Fix panic in open subcommand when spaceout is missing Fix data inconsistency handling in prepare_open that caused panics when opening spaces that were revoked due to expiration. Root cause: When an outpoint exists but spaceout doesn't (due to inconsistent state from Expired revocations), the code would panic with 'spaceout exists' instead of handling it gracefully. Changes: - Replace expect() with match statement to handle None case - Treat missing spaceout as new space (space was revoked, so it's effectively not registered anymore) --- protocol/src/script.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/protocol/src/script.rs b/protocol/src/script.rs index b2f77b8..95a53e0 100644 --- a/protocol/src/script.rs +++ b/protocol/src/script.rs @@ -148,10 +148,18 @@ impl SpaceScript { let existing = src.get_space_outpoint(&spacehash)?; match existing { None => OpenHistory::NewSpace(name.to_owned()), - Some(outpoint) => OpenHistory::ExistingSpace(FullSpaceOut { - txid: outpoint.txid, - spaceout: src.get_spaceout(&outpoint)?.expect("spaceout exists"), - }), + Some(outpoint) => { + // Handle data inconsistency: if spaceout doesn't exist, treat as new space + // This can happen if the space was revoked but the space->outpoint mapping + // wasn't cleaned up properly + match src.get_spaceout(&outpoint)? { + Some(spaceout) => OpenHistory::ExistingSpace(FullSpaceOut { + txid: outpoint.txid, + spaceout, + }), + None => OpenHistory::NewSpace(name.to_owned()), + } + } } }; let open = Ok(kind); From b3c6b55c463845d01926d5c87bf17bcd63a606e6 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Sat, 13 Dec 2025 20:08:47 +0100 Subject: [PATCH 06/20] Bump version --- Cargo.lock | 6 +++--- client/Cargo.toml | 2 +- protocol/Cargo.toml | 2 +- wallet/Cargo.toml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 44d0ef8..fce6a06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2332,7 +2332,7 @@ dependencies = [ [[package]] name = "spaces_client" -version = "0.0.7" +version = "0.0.8" dependencies = [ "anyhow", "assert_cmd", @@ -2366,7 +2366,7 @@ dependencies = [ [[package]] name = "spaces_protocol" -version = "0.0.7" +version = "0.0.8" dependencies = [ "bincode", "bitcoin", @@ -2408,7 +2408,7 @@ dependencies = [ [[package]] name = "spaces_wallet" -version = "0.0.7" +version = "0.0.8" dependencies = [ "anyhow", "bdk_wallet", diff --git a/client/Cargo.toml b/client/Cargo.toml index dc13ed5..ac0e48b 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spaces_client" -version = "0.0.7" +version = "0.0.8" edition = "2021" diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml index 3832091..5d30094 100644 --- a/protocol/Cargo.toml +++ b/protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spaces_protocol" -version = "0.0.7" +version = "0.0.8" edition = "2021" [dependencies] diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 779239b..d1ff2b8 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spaces_wallet" -version = "0.0.7" +version = "0.0.8" edition = "2021" [dependencies] From b50b5ca04f15a3244be2fe13be4fd2371a6f4f52 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Sat, 13 Dec 2025 20:15:43 +0100 Subject: [PATCH 07/20] Fix macos gh actions --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7be1853..78a4fe2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: target: aarch64-unknown-linux-gnu - os: macos-latest target: aarch64-apple-darwin - - os: macos-13 + - os: macos-15-intel target: x86_64-apple-darwin steps: From e53e4d46f6750e75306809a4637a6e7db89bb9a1 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Sat, 13 Dec 2025 20:28:55 +0100 Subject: [PATCH 08/20] Fix rust lifetimes lint --- protocol/src/lib.rs | 2 +- protocol/src/slabel.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index a8f6c63..7bf679d 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -350,7 +350,7 @@ impl FullSpaceOut { pub fn refund_signing_info( &self, - ) -> Option<(Transaction, Prevouts, schnorr::Signature)> { + ) -> Option<(Transaction, Prevouts<'_, TxOut>, schnorr::Signature)> { if self.spaceout.space.is_none() { return None; } diff --git a/protocol/src/slabel.rs b/protocol/src/slabel.rs index 4349309..28265d2 100644 --- a/protocol/src/slabel.rs +++ b/protocol/src/slabel.rs @@ -248,7 +248,7 @@ impl Display for SLabelRef<'_> { } impl SLabel { - pub fn as_name_ref(&self) -> SLabelRef { + pub fn as_name_ref(&self) -> SLabelRef<'_> { SLabelRef(&self.0) } From c852e3db35eca671e7835117c48e046fc00c9e87 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Sat, 13 Dec 2025 21:14:16 +0100 Subject: [PATCH 09/20] Fix lint errors --- Cargo.lock | 8 ++++---- Cargo.toml | 5 +++++ client/Cargo.toml | 4 ++-- client/src/bin/space-cli.rs | 25 ------------------------- client/src/store.rs | 4 ++-- protocol/Cargo.toml | 4 ++-- veritas/Cargo.toml | 4 ++-- veritas/src/lib.rs | 2 +- wallet/Cargo.toml | 4 ++-- wallet/src/builder.rs | 3 +-- wallet/src/lib.rs | 8 ++++---- 11 files changed, 25 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fce6a06..37382e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2332,7 +2332,7 @@ dependencies = [ [[package]] name = "spaces_client" -version = "0.0.8" +version = "0.0.9" dependencies = [ "anyhow", "assert_cmd", @@ -2366,7 +2366,7 @@ dependencies = [ [[package]] name = "spaces_protocol" -version = "0.0.8" +version = "0.0.9" dependencies = [ "bincode", "bitcoin", @@ -2390,7 +2390,7 @@ dependencies = [ [[package]] name = "spaces_veritas" -version = "0.0.7" +version = "0.0.9" dependencies = [ "base64 0.22.1", "bincode", @@ -2408,7 +2408,7 @@ dependencies = [ [[package]] name = "spaces_wallet" -version = "0.0.8" +version = "0.0.9" dependencies = [ "anyhow", "bdk_wallet", diff --git a/Cargo.toml b/Cargo.toml index fc35b08..a35b186 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,3 +2,8 @@ resolver = "2" members = [ "client", "protocol", "veritas", "testutil", "wallet"] + + +[workspace.package] +version = "0.0.9" +edition = "2021" \ No newline at end of file diff --git a/client/Cargo.toml b/client/Cargo.toml index ac0e48b..0b96093 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spaces_client" -version = "0.0.8" -edition = "2021" +version.workspace = true +edition.workspace = true [[bin]] diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 3e89fe6..0118960 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -18,11 +18,9 @@ use jsonrpsee::{ core::{client::Error, ClientError}, http_client::HttpClient, }; -use serde::{Deserialize, Serialize}; use spaces_client::{ auth::{auth_token_from_cookie, auth_token_from_creds, http_client_with_auth}, config::{default_cookie_path, default_spaces_rpc_port, ExtendedNetwork}, - deserialize_base64, format::{ print_error_rpc_response, print_list_bidouts, print_list_spaces_response, print_list_transactions, print_list_unspent, print_list_wallets, print_server_info, @@ -32,7 +30,6 @@ use spaces_client::{ BidParams, ExecuteParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest, RpcWalletTxBuilder, SendCoinsParams, TransferSpacesParams, }, - serialize_base64, wallets::{AddressKind, WalletResponse}, }; use spaces_protocol::bitcoin::{Amount, FeeRate, OutPoint, Txid}; @@ -369,28 +366,6 @@ struct SpaceCli { client: HttpClient, } -#[derive(Serialize, Deserialize)] -struct SignedDnsUpdate { - serial: u32, - space: String, - #[serde( - serialize_with = "serialize_base64", - deserialize_with = "deserialize_base64" - )] - packet: Vec, - signature: Signature, - #[serde(skip_serializing_if = "Option::is_none")] - proof: Option, -} - -#[derive(Serialize, Deserialize)] -struct Base64Bytes( - #[serde( - serialize_with = "serialize_base64", - deserialize_with = "deserialize_base64" - )] - Vec, -); impl SpaceCli { async fn configure() -> anyhow::Result<(Self, Args)> { diff --git a/client/src/store.rs b/client/src/store.rs index 95a5f63..a43b683 100644 --- a/client/src/store.rs +++ b/client/src/store.rs @@ -89,11 +89,11 @@ impl Store { Ok(Database::new(Box::new(FileBackend::new(file)?), config)?) } - pub fn iter(&self) -> SnapshotIterator { + pub fn iter(&self) -> SnapshotIterator<'_, Sha256Hasher> { return self.0.iter(); } - pub fn write(&self) -> Result { + pub fn write(&self) -> Result> { Ok(self.0.begin_write()?) } diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml index 5d30094..60d7712 100644 --- a/protocol/Cargo.toml +++ b/protocol/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spaces_protocol" -version = "0.0.8" -edition = "2021" +version.workspace = true +edition.workspace = true [dependencies] bitcoin = { version = "0.32.2", features = ["base64", "serde"], default-features = false } diff --git a/veritas/Cargo.toml b/veritas/Cargo.toml index 5092c8f..e19ac08 100644 --- a/veritas/Cargo.toml +++ b/veritas/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spaces_veritas" -version = "0.0.7" -edition = "2021" +version.workspace = true +edition.workspace = true [lib] crate-type = ["cdylib", "rlib"] diff --git a/veritas/src/lib.rs b/veritas/src/lib.rs index 221a6f3..f530923 100644 --- a/veritas/src/lib.rs +++ b/veritas/src/lib.rs @@ -103,7 +103,7 @@ impl Veritas { } impl Proof { - pub fn iter(&self) -> ProofIter { + pub fn iter(&self) -> ProofIter<'_> { ProofIter { inner: self.inner.iter(), } diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index d1ff2b8..1854f52 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "spaces_wallet" -version = "0.0.8" -edition = "2021" +version.workspace = true +edition.workspace = true [dependencies] spaces_protocol = { path = "../protocol", features = ["std"], version = "*" } diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index 5f94516..f41a3e0 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -405,7 +405,6 @@ impl Builder { if !coin_transfers.is_empty() { for coin in coin_transfers { builder.add_send(coin)?; - vout += 1; } } @@ -720,7 +719,7 @@ impl Builder { wallet: &mut SpacesWallet, unspendables: Vec, confirmed_only: bool, - ) -> anyhow::Result { + ) -> anyhow::Result> { let fee_rate = self .fee_rate .as_ref() diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 82ea6b2..0ebf947 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -256,7 +256,7 @@ impl SpacesWallet { }) } - pub fn get_tx(&mut self, txid: Txid) -> Option { + pub fn get_tx(&mut self, txid: Txid) -> Option> { self.internal.get_tx(txid) } @@ -281,7 +281,7 @@ impl SpacesWallet { }) } - pub fn transactions(&self) -> impl Iterator + '_ { + pub fn transactions(&self) -> impl Iterator> + '_ { self.internal .transactions() .filter(|tx| !is_revert_tx(tx) && self.internal.spk_index().is_tx_relevant(&tx.tx_node)) @@ -299,7 +299,7 @@ impl SpacesWallet { &mut self, unspendables: Vec, confirmed_only: bool, - ) -> anyhow::Result> { + ) -> anyhow::Result> { self.create_builder(unspendables, None, confirmed_only) } @@ -530,7 +530,7 @@ impl SpacesWallet { /// /// This is used to monitor bid txs in the mempool /// to check if they have been replaced. - pub fn unconfirmed_bids(&mut self) -> anyhow::Result> { + pub fn unconfirmed_bids(&mut self) -> anyhow::Result, OutPoint)>> { let txids: Vec<_> = { let unconfirmed: Vec<_> = self .transactions() From 789f78c1996ab62631b10bea66c581022fef0bed Mon Sep 17 00:00:00 2001 From: Alex Tsokurov Date: Thu, 18 Dec 2025 20:16:36 +0100 Subject: [PATCH 10/20] Fix listspaces performance --- client/src/wallets.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/client/src/wallets.rs b/client/src/wallets.rs index fda04a4..e17391c 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -820,12 +820,6 @@ impl RpcWallet { let mut pending = vec![]; let mut outbid = vec![]; for (txid, event) in recent_events { - let tx = wallet.get_tx(txid); - if tx.as_ref().is_some_and(|tx| !tx.chain_position.is_confirmed()) { - pending.push(SLabel::from_str(event.space.as_ref().unwrap()).expect("valid space name")); - continue; - } - if unspent.iter().any(|out| { out.space .as_ref() @@ -833,6 +827,13 @@ impl RpcWallet { }) { continue; } + + let tx = wallet.get_tx(txid); + if tx.as_ref().is_some_and(|tx| !tx.chain_position.is_confirmed()) { + pending.push(SLabel::from_str(event.space.as_ref().unwrap()).expect("valid space name")); + continue; + } + let name = SLabel::from_str(event.space.as_ref().unwrap()).expect("valid space name"); let spacehash = SpaceKey::from(Sha256::hash(name.as_ref())); let space = state.get_space_info(&spacehash)?; From 4f2d3c6ae6dce68c015f4100dedb542cdaa91e02 Mon Sep 17 00:00:00 2001 From: spacesops Date: Tue, 31 Mar 2026 16:11:50 -0400 Subject: [PATCH 11/20] resurrected signevent --- client/src/bin/space-cli.rs | 68 ++++++++++++++++++++++++++++++++----- client/src/rpc.rs | 23 ++++++++++++- client/src/wallets.rs | 29 ++++++++++++++++ 3 files changed, 110 insertions(+), 10 deletions(-) diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 5129821..e340353 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -2,7 +2,7 @@ extern crate core; use std::{ fs, io, - io::Write, + io::{BufRead, IsTerminal, Write}, path::PathBuf, }; use std::str::FromStr; @@ -33,7 +33,7 @@ use spaces_client::store::Sha256; use spaces_protocol::bitcoin::{Amount, FeeRate, OutPoint, Txid}; use spaces_protocol::slabel::SLabel; use spaces_nums::num_id::NumId; -use spaces_wallet::{bitcoin::secp256k1::schnorr::Signature, export::WalletExport, Listing}; +use spaces_wallet::{bitcoin::secp256k1::schnorr::Signature, export::WalletExport, nostr::NostrEvent, Listing}; use spaces_wallet::bitcoin::hashes::sha256; use spaces_wallet::bitcoin::ScriptBuf; @@ -361,6 +361,7 @@ enum Commands { /// space-cli setfallback @alice --txt btc=bc1q... --txt nostr=npub1... /// space-cli setfallback @alice --raw SGVsbG8= /// echo '[{"type":"txt","key":"btc","value":["bc1q..."]}]' | space-cli setfallback @alice --stdin + /// space-cli setfallback @alice --txt btc=bc1q... --dry-run #[command(name = "setfallback")] SetFallback { /// Space name, numeric, or num id @@ -380,6 +381,10 @@ enum Commands { /// Fee rate to use in sat/vB #[arg(long, short)] fee_rate: Option, + /// Print hex of the OP_RETURN data payload (SIP-7 bytes) and exit (no RPC / no transaction). + /// SUBJECT is still required by the parser but ignored. + #[arg(long)] + dry_run: bool, }, /// Get on-chain fallback record data for a space or num. #[command(name = "getfallback")] @@ -387,6 +392,15 @@ enum Commands { /// Space name, numeric, or num id subject: Subject, }, + /// Sign a Nostr event using the space's private key + #[command(name = "signevent")] + SignEvent { + /// Space name (e.g., @example) + space: String, + /// Path to a Nostr event JSON file (omit for stdin) + #[arg(short, long)] + input: Option, + }, /// List last transactions #[command(name = "listtransactions")] ListTransactions { @@ -745,6 +759,7 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client raw, stdin, fee_rate, + dry_run, } => { use base64::Engine; let data = if let Some(raw_b64) = raw { @@ -783,13 +798,17 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client )); }; - cli.send_request( - Some(RpcWalletRequest::SetFallback(SetFallbackParams { subject, data })), - None, - fee_rate, - false, - ) - .await?; + if dry_run { + println!("{}", hex::encode(&data)); + } else { + cli.send_request( + Some(RpcWalletRequest::SetFallback(SetFallbackParams { subject, data })), + None, + fee_rate, + false, + ) + .await?; + } } Commands::GetFallback { subject, @@ -797,6 +816,18 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client let response = cli.client.get_fallback(subject).await?; println!("{}", serde_json::to_string_pretty(&response)?); } + Commands::SignEvent { mut space, input } => { + let event = read_event(input) + .map_err(|e| ClientError::Custom(format!("input error: {}", e)))?; + space = normalize_space(&space); + let subject = Subject::from_str(&space) + .map_err(|e| ClientError::Custom(e.to_string()))?; + let result = cli + .client + .wallet_sign_event(&cli.wallet, subject, event) + .await?; + println!("{}", serde_json::to_string(&result).expect("result")); + } Commands::ListUnspent => { let utxos = cli.client.wallet_list_unspent(&cli.wallet).await?; print_list_unspent(utxos, cli.format); @@ -1046,3 +1077,22 @@ fn default_rpc_url(chain: &ExtendedNetwork) -> String { format!("http://127.0.0.1:{}", default_spaces_rpc_port(chain)) } +fn read_event(file: Option) -> anyhow::Result { + let content = get_input(file)?; + let event: NostrEvent = serde_json::from_str(&content)?; + Ok(event) +} + +fn get_input(input: Option) -> anyhow::Result { + Ok(match input { + Some(file) => fs::read_to_string(file)?, + None => { + let stdin = io::stdin(); + if stdin.is_terminal() { + return Err(anyhow!("no input provided: specify a file path or pipe via stdin")); + } + stdin.lock().lines().collect::>()? + } + }) +} + diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 2cd2dd1..008728f 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -40,7 +40,7 @@ use spaces_protocol::{ use spaces_wallet::{ bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash as BitcoinHash, bitcoin::secp256k1::schnorr, - export::WalletExport, Balance, DoubleUtxo, Listing, SpacesWallet, + export::WalletExport, nostr::NostrEvent, Balance, DoubleUtxo, Listing, SpacesWallet, WalletConfig, WalletDescriptors, WalletOutput, }; pub use spaces_wallet::Subject; @@ -317,6 +317,14 @@ pub trait Rpc { message: Bytes, ) -> Result; + #[method(name = "walletsignevent")] + async fn wallet_sign_event( + &self, + wallet: &str, + subject: Subject, + event: NostrEvent, + ) -> Result; + #[method(name = "verifyschnorr")] async fn verify_schnorr( &self, @@ -1167,6 +1175,19 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn wallet_sign_event( + &self, + wallet: &str, + subject: Subject, + event: NostrEvent, + ) -> Result { + self.wallet(&wallet) + .await? + .send_sign_event(subject, event) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn verify_schnorr( &self, subject: Subject, diff --git a/client/src/wallets.rs b/client/src/wallets.rs index 06200ed..b2f7a7c 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -25,6 +25,7 @@ use spaces_wallet::{ bitcoin::{secp256k1::schnorr, Address, Amount, FeeRate, OutPoint}, builder::{CoinTransfer, SpaceTransfer, SpacesAwareCoinSelection}, tx_event::{TxEvent, TxEventKind, TxRecord}, + nostr::NostrEvent, Balance, DoubleUtxo, Listing, SpacesWallet, Subject, WalletInfo, WalletOutput, }; @@ -350,6 +351,11 @@ pub enum WalletCommand { subject: Subject, resp: crate::rpc::Responder>, }, + SignEvent { + subject: Subject, + event: NostrEvent, + resp: crate::rpc::Responder>, + }, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum)] @@ -719,6 +725,13 @@ impl RpcWallet { let result = Self::can_operate(wallet, chain, &subject); _ = resp.send(result); } + WalletCommand::SignEvent { + subject, + event, + resp, + } => { + _ = resp.send(wallet.sign_event::(chain, subject, event)); + } } Ok(()) } @@ -2101,6 +2114,22 @@ impl RpcWallet { .await?; resp_rx.await? } + + pub async fn send_sign_event( + &self, + subject: Subject, + event: NostrEvent, + ) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(WalletCommand::SignEvent { + subject, + event, + resp, + }) + .await?; + resp_rx.await? + } } // Extracts fee rate from example rpc message: "insufficient fee, rejecting replacement From 30fdac85a4294978db53d1f4365150910fb39946 Mon Sep 17 00:00:00 2001 From: spacesops Date: Tue, 31 Mar 2026 18:03:35 -0400 Subject: [PATCH 12/20] resurrected verifyevent --- client/src/bin/space-cli.rs | 22 ++++++++++++++++++++++ client/src/rpc.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index e340353..6f1cfd9 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -401,6 +401,15 @@ enum Commands { #[arg(short, long)] input: Option, }, + /// Verify a signed Nostr event against the space's or numeric's public key + #[command(name = "verifyevent")] + VerifyEvent { + /// Space or numeric subject (e.g., @example) + space: String, + /// Path to a signed Nostr event JSON file (omit for stdin) + #[arg(short, long)] + input: Option, + }, /// List last transactions #[command(name = "listtransactions")] ListTransactions { @@ -828,6 +837,19 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client .await?; println!("{}", serde_json::to_string(&result).expect("result")); } + Commands::VerifyEvent { mut space, input } => { + let event = read_event(input) + .map_err(|e| ClientError::Custom(format!("input error: {}", e)))?; + space = normalize_space(&space); + let subject = Subject::from_str(&space) + .map_err(|e| ClientError::Custom(e.to_string()))?; + let event = cli + .client + .verify_event(subject, event) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + println!("{}", serde_json::to_string(&event).expect("result")); + } Commands::ListUnspent => { let utxos = cli.client.wallet_list_unspent(&cli.wallet).await?; print_list_unspent(utxos, cli.format); diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 008728f..a6c13e1 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -199,6 +199,11 @@ pub enum ChainStateCommand { signature: Vec, resp: Responder>, }, + VerifyEvent { + subject: Subject, + event: NostrEvent, + resp: Responder>, + }, BuildChainProof { request: ChainProofRequest, prefer_recent: bool, @@ -325,6 +330,13 @@ pub trait Rpc { event: NostrEvent, ) -> Result; + #[method(name = "verifyevent")] + async fn verify_event( + &self, + subject: Subject, + event: NostrEvent, + ) -> Result; + #[method(name = "verifyschnorr")] async fn verify_schnorr( &self, @@ -1201,6 +1213,17 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn verify_event( + &self, + subject: Subject, + event: NostrEvent, + ) -> Result { + self.store + .verify_event(subject, event) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn wallet_get_info( &self, wallet: &str, @@ -1719,6 +1742,9 @@ impl AsyncChainState { })(); _ = resp.send(result); } + ChainStateCommand::VerifyEvent { subject, event, resp } => { + _ = resp.send(SpacesWallet::verify_event::(state, subject, event)); + } ChainStateCommand::BuildChainProof { request, prefer_recent, @@ -2034,6 +2060,14 @@ impl AsyncChainState { resp_rx.await? } + pub async fn verify_event(&self, subject: Subject, event: NostrEvent) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::VerifyEvent { subject, event, resp }) + .await?; + resp_rx.await? + } + pub async fn build_chain_proof( &self, request: ChainProofRequest, From cac4ea4d0125205b78466df48c8772569a421969 Mon Sep 17 00:00:00 2001 From: spacesops Date: Wed, 1 Apr 2026 10:44:40 -0400 Subject: [PATCH 13/20] Add transaction callback system from tx-callback branch Implements the TX_CALLBACK_API: clients can register HTTP endpoints and watch-lists of txids; when a watched tx appears in a block the daemon POSTs a JSON notification asynchronously without blocking block processing. Co-Authored-By: Claude Sonnet 4.6 --- client/src/app.rs | 14 ++- client/src/callbacks.rs | 248 ++++++++++++++++++++++++++++++++++++++++ client/src/lib.rs | 1 + client/src/rpc.rs | 71 ++++++++++++ client/src/spaces.rs | 16 ++- 5 files changed, 343 insertions(+), 7 deletions(-) create mode 100644 client/src/callbacks.rs diff --git a/client/src/app.rs b/client/src/app.rs index dc219a2..6af1ede 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -4,6 +4,7 @@ use anyhow::anyhow; use tokio::sync::{broadcast, mpsc}; use tokio::task::{JoinHandle, JoinSet}; use crate::config::Args; +use crate::callbacks::CallbackRegistry; use crate::rpc::{AsyncChainState, RpcServerImpl, WalletLoadRequest, WalletManager}; use crate::source::{BitcoinBlockSource, BitcoinRpc}; use crate::spaces::Spaced; @@ -41,7 +42,7 @@ impl App { }); } - async fn setup_rpc_services(&mut self, spaced: &Spaced) { + async fn setup_rpc_services(&mut self, spaced: &Spaced, callback_registry: CallbackRegistry) { let (wallet_loader_tx, wallet_loader_rx) = mpsc::channel(1); let wallet_manager = WalletManager { @@ -66,7 +67,7 @@ impl App { .await .map_err(|e| anyhow!("Chain state error: {}", e)) }); - let rpc_server = RpcServerImpl::new(async_chain_state.clone(), wallet_manager); + let rpc_server = RpcServerImpl::new_with_callbacks(async_chain_state.clone(), wallet_manager, callback_registry); let bind = spaced.bind.clone(); let auth_token = spaced.auth_token.clone(); @@ -82,7 +83,7 @@ impl App { self.setup_rpc_wallet(spaced, wallet_loader_rx, spaced.cbf).await; } - async fn setup_sync_service(&mut self, mut spaced: Spaced) { + async fn setup_sync_service(&mut self, mut spaced: Spaced, callback_registry: CallbackRegistry) { let (spaced_sender, spaced_receiver) = tokio::sync::oneshot::channel(); let shutdown = self.shutdown.clone(); @@ -90,7 +91,7 @@ impl App { std::thread::spawn(move || { let source = BitcoinBlockSource::new(rpc); - _ = spaced_sender.send(spaced.protocol_sync(source, shutdown)); + _ = spaced_sender.send(spaced.protocol_sync(source, shutdown, callback_registry)); }); self.services.spawn(async move { @@ -102,8 +103,9 @@ impl App { pub async fn run(&mut self, args: Vec) -> anyhow::Result<()> { let spaced = Args::configure(args).await?; - self.setup_rpc_services(&spaced).await; - self.setup_sync_service(spaced).await; + let callback_registry = CallbackRegistry::new(); + self.setup_rpc_services(&spaced, callback_registry.clone()).await; + self.setup_sync_service(spaced, callback_registry).await; while let Some(res) = self.services.join_next().await { res?? diff --git a/client/src/callbacks.rs b/client/src/callbacks.rs new file mode 100644 index 0000000..5b4d450 --- /dev/null +++ b/client/src/callbacks.rs @@ -0,0 +1,248 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use anyhow::Result; +use spaces_protocol::bitcoin::Txid; +use log::{error, info, warn}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use reqwest::Client as HttpClient; + +/// Represents a registered callback client +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallbackClient { + /// Unique identifier for this client + pub client_id: String, + /// URL endpoint to call when a watched transaction is found + pub callback_url: String, + /// Set of transaction IDs this client is watching for + pub watched_txids: HashSet, + /// Timestamp when this client was registered + pub registered_at: u64, +} + +/// Notification payload sent to callback URLs +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionNotification { + /// The transaction ID that was found + pub txid: Txid, + /// Block height where the transaction was included + pub block_height: u32, + /// Block hash where the transaction was included + pub block_hash: String, + /// Number of confirmations (1 for the block it was included in) + pub confirmations: u32, + /// Current chain tip height + pub chain_tip_height: u32, + /// Timestamp when the notification was sent + pub notified_at: u64, +} + +/// Registry for managing transaction callbacks +#[derive(Clone)] +pub struct CallbackRegistry { + /// Map from client_id to CallbackClient + clients: Arc>>, + /// Reverse map from txid to set of client_ids watching it + txid_to_clients: Arc>>>, + /// HTTP client for making callback requests + http_client: Arc, +} + +impl CallbackRegistry { + pub fn new() -> Self { + Self { + clients: Arc::new(RwLock::new(HashMap::new())), + txid_to_clients: Arc::new(RwLock::new(HashMap::new())), + http_client: Arc::new(HttpClient::new()), + } + } + + /// Register a new callback client + pub async fn register_client( + &self, + client_id: String, + callback_url: String, + ) -> Result<()> { + let mut clients = self.clients.write().await; + let mut txid_map = self.txid_to_clients.write().await; + + // If client already exists, remove old watched txids from reverse map + if let Some(old_client) = clients.get(&client_id) { + for txid in &old_client.watched_txids { + if let Some(client_set) = txid_map.get_mut(txid) { + client_set.remove(&client_id); + if client_set.is_empty() { + txid_map.remove(txid); + } + } + } + } + + let client = CallbackClient { + client_id: client_id.clone(), + callback_url, + watched_txids: HashSet::new(), + registered_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + }; + + clients.insert(client_id, client); + Ok(()) + } + + /// Unregister a callback client + pub async fn unregister_client(&self, client_id: &str) -> Result { + let mut clients = self.clients.write().await; + let mut txid_map = self.txid_to_clients.write().await; + + if let Some(client) = clients.remove(client_id) { + // Remove all watched txids from reverse map + for txid in &client.watched_txids { + if let Some(client_set) = txid_map.get_mut(txid) { + client_set.remove(client_id); + if client_set.is_empty() { + txid_map.remove(txid); + } + } + } + Ok(true) + } else { + Ok(false) + } + } + + /// Update the list of watched transaction IDs for a client + pub async fn update_watched_txids( + &self, + client_id: &str, + txids: Vec, + ) -> Result { + let mut clients = self.clients.write().await; + let mut txid_map = self.txid_to_clients.write().await; + + if let Some(client) = clients.get_mut(client_id) { + // Remove old txids from reverse map + for txid in &client.watched_txids { + if let Some(client_set) = txid_map.get_mut(txid) { + client_set.remove(client_id); + if client_set.is_empty() { + txid_map.remove(txid); + } + } + } + + // Update client's watched txids + let new_txids: HashSet = txids.into_iter().collect(); + client.watched_txids = new_txids.clone(); + + // Add new txids to reverse map + for txid in &new_txids { + txid_map + .entry(*txid) + .or_insert_with(HashSet::new) + .insert(client_id.to_string()); + } + + Ok(true) + } else { + Ok(false) + } + } + + /// Get information about a registered client + pub async fn get_client(&self, client_id: &str) -> Option { + let clients = self.clients.read().await; + clients.get(client_id).cloned() + } + + /// List all registered clients + pub async fn list_clients(&self) -> Vec { + let clients = self.clients.read().await; + clients.values().cloned().collect() + } + + /// Check if any watched transactions are in a block and notify clients + pub async fn check_and_notify( + &self, + block_height: u32, + block_hash: &str, + block_txids: &[Txid], + chain_tip_height: u32, + ) { + let txid_map = self.txid_to_clients.read().await; + let clients = self.clients.read().await; + + let mut notifications = Vec::new(); + + // Find which clients need to be notified + for txid in block_txids { + if let Some(client_ids) = txid_map.get(txid) { + for client_id in client_ids { + if let Some(client) = clients.get(client_id) { + let confirmations = chain_tip_height.saturating_sub(block_height) + 1; + notifications.push(( + client.clone(), + TransactionNotification { + txid: *txid, + block_height, + block_hash: block_hash.to_string(), + confirmations, + chain_tip_height, + notified_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + }, + )); + } + } + } + } + + // Send notifications asynchronously + for (client, notification) in notifications { + let http_client = self.http_client.clone(); + let callback_url = client.callback_url.clone(); + let client_id = client.client_id.clone(); + + tokio::spawn(async move { + match http_client + .post(&callback_url) + .json(¬ification) + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + info!( + "Successfully notified client {} about txid {} at block {}", + client_id, notification.txid, block_height + ); + } else { + warn!( + "Callback to {} returned status {} for txid {}", + callback_url, + response.status(), + notification.txid + ); + } + } + Err(e) => { + error!( + "Failed to send callback to {} for txid {}: {}", + callback_url, notification.txid, e + ); + } + } + }); + } + } +} + +impl Default for CallbackRegistry { + fn default() -> Self { + Self::new() + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs index 934d626..658636d 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -10,6 +10,7 @@ use base64::Engine; use serde::{Deserialize, Deserializer, Serializer}; pub mod auth; +pub mod callbacks; mod checker; pub mod client; pub mod config; diff --git a/client/src/rpc.rs b/client/src/rpc.rs index a6c13e1..18bf568 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -297,6 +297,21 @@ pub trait Rpc { #[method(name = "gettxmeta")] async fn get_tx_meta(&self, txid: Txid) -> Result, ErrorObjectOwned>; + #[method(name = "registertxcallback")] + async fn register_tx_callback(&self, client_id: String, callback_url: String) -> Result<(), ErrorObjectOwned>; + + #[method(name = "unregistertxcallback")] + async fn unregister_tx_callback(&self, client_id: String) -> Result; + + #[method(name = "updatetxwatches")] + async fn update_tx_watches(&self, client_id: String, txids: Vec) -> Result; + + #[method(name = "gettxcallback")] + async fn get_tx_callback(&self, client_id: String) -> Result, ErrorObjectOwned>; + + #[method(name = "listtxcallbacks")] + async fn list_tx_callbacks(&self) -> Result, ErrorObjectOwned>; + #[method(name = "listwallets")] async fn list_wallets(&self) -> Result, ErrorObjectOwned>; @@ -617,6 +632,7 @@ pub struct RpcServerImpl { wallet_manager: WalletManager, store: AsyncChainState, client: reqwest::Client, + callback_registry: crate::callbacks::CallbackRegistry, } @@ -879,13 +895,26 @@ impl WalletManager { impl RpcServerImpl { pub fn new(store: AsyncChainState, wallet_manager: WalletManager) -> Self { + Self::new_with_callbacks(store, wallet_manager, crate::callbacks::CallbackRegistry::new()) + } + + pub fn new_with_callbacks( + store: AsyncChainState, + wallet_manager: WalletManager, + callback_registry: crate::callbacks::CallbackRegistry, + ) -> Self { RpcServerImpl { wallet_manager, store, client: reqwest::Client::new(), + callback_registry, } } + pub fn callback_registry(&self) -> &crate::callbacks::CallbackRegistry { + &self.callback_registry + } + async fn wallet(&self, wallet: &str) -> Result { let wallets = self.wallet_manager.wallets.read().await; wallets.get(wallet).cloned().ok_or_else(|| { @@ -1224,6 +1253,48 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } + async fn register_tx_callback( + &self, + client_id: String, + callback_url: String, + ) -> Result<(), ErrorObjectOwned> { + self.callback_registry + .register_client(client_id, callback_url) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn unregister_tx_callback(&self, client_id: String) -> Result { + self.callback_registry + .unregister_client(&client_id) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn update_tx_watches( + &self, + client_id: String, + txids: Vec, + ) -> Result { + self.callback_registry + .update_watched_txids(&client_id, txids) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn get_tx_callback( + &self, + client_id: String, + ) -> Result, ErrorObjectOwned> { + Ok(self.callback_registry.get_client(&client_id).await) + } + + async fn list_tx_callbacks( + &self, + ) -> Result, ErrorObjectOwned> { + Ok(self.callback_registry.list_clients().await) + } + async fn wallet_get_info( &self, wallet: &str, diff --git a/client/src/spaces.rs b/client/src/spaces.rs index 432776d..8059f7e 100644 --- a/client/src/spaces.rs +++ b/client/src/spaces.rs @@ -8,6 +8,7 @@ use spaces_protocol::{ use tokio::sync::broadcast; use crate::{ + callbacks::CallbackRegistry, client::{BlockSource, Client}, config::ExtendedNetwork, source::{ @@ -80,6 +81,7 @@ impl Spaced { node: &mut Client, id: ChainAnchor, block: Block, + callback_registry: &CallbackRegistry, ) -> anyhow::Result<()> { let sp_idx = self.chain.has_spaces_index(); let pt_idx = self.chain.has_nums_index(); @@ -101,6 +103,17 @@ impl Spaced { if self.chain.maybe_commit(new_tip)? { self.update_anchors()?; } + + let block_txids: Vec<_> = block.txdata.iter().map(|tx| tx.compute_txid()).collect(); + let chain_tip = self.chain.tip(); + let block_hash_str = id.hash.to_string(); + if let Ok(handle) = tokio::runtime::Handle::try_current() { + let registry = callback_registry.clone(); + handle.spawn(async move { + registry.check_and_notify(id.height, &block_hash_str, &block_txids, chain_tip.height).await; + }); + } + Ok(()) } @@ -108,6 +121,7 @@ impl Spaced { &mut self, source: BitcoinBlockSource, shutdown: broadcast::Sender<()>, + callback_registry: CallbackRegistry, ) -> anyhow::Result<()> { let start_block = self.chain.tip(); let mut node = Client::new(self.block_index_full); @@ -149,7 +163,7 @@ impl Spaced { } } BlockEvent::Block(id, block) => { - self.handle_block(&mut node, id, block)?; + self.handle_block(&mut node, id, block, &callback_registry)?; info!("block={} height={}", id.hash, id.height); if self.enable_pruning && id.height % PRUNING_BUFFER == 0 { self.prune(&source, id.height); From ba90345eb3431c806bb264de34393e01e40d1fb8 Mon Sep 17 00:00:00 2001 From: spacesops Date: Wed, 1 Apr 2026 11:29:29 -0400 Subject: [PATCH 14/20] Add estimatefee command from pr-127 branch Exposes Bitcoin Core estimatesmartfee via RPC and CLI, converting BTC/kB to sat/vB for convenient fee estimation. Made-with: Cursor --- client/src/bin/space-cli.rs | 22 ++++++++++++++ client/src/rpc.rs | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 6f1cfd9..009c715 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -256,6 +256,16 @@ enum Commands { #[arg(default_value = "0")] target: usize, }, + /// Estimate fee rate for a given confirmation target + #[command(name = "estimatefee")] + EstimateFee { + /// Target number of blocks for confirmation (1-1008) + #[arg(default_value = "6")] + conf_target: u32, + /// Fee estimation mode: unset, conservative, or economical + #[arg(long, short)] + mode: Option, + }, /// Send the specified amount of BTC to the given name or address #[command( name = "send", @@ -609,6 +619,18 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client let response = cli.client.estimate_bid(target).await?; println!("{} sat", Amount::from_sat(response).to_sat()); } + Commands::EstimateFee { conf_target, mode } => { + let response = cli.client.estimate_fee(conf_target, mode).await?; + match cli.format { + Format::Text => { + println!("Fee rate: {} sat/vB", response.feerate_sat_vb); + println!("Blocks: {}", response.blocks); + } + Format::Json => { + println!("{}", serde_json::to_string_pretty(&response)?); + } + } + } Commands::GetSpace { space } => { let space = normalize_space(&space); let response = cli.client.get_space(&space).await?; diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 18bf568..21732de 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -479,11 +479,25 @@ pub trait Rpc { subject: Subject, ) -> Result, ErrorObjectOwned>; + #[method(name = "estimatefee")] + async fn estimate_fee( + &self, + conf_target: u32, + estimate_mode: Option, + ) -> Result; + /// Debug method to set a space's expire height (regtest only) #[method(name = "debugsetexpireheight")] async fn debug_set_expire_height(&self, space: &str, expire_height: u32) -> Result<(), ErrorObjectOwned>; } +#[derive(Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct FeeEstimateResponse { + pub feerate_sat_vb: u64, + pub blocks: u64, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct FallbackResponse { @@ -1548,6 +1562,49 @@ impl RpcServer for RpcServerImpl { } } + async fn estimate_fee( + &self, + conf_target: u32, + estimate_mode: Option, + ) -> Result { + let mode = estimate_mode.unwrap_or_else(|| "unset".to_string()); + let params = serde_json::json!([conf_target, mode]); + let rpc = self.wallet_manager.rpc.clone(); + + let estimate_req = rpc.make_request("estimatesmartfee", params); + + let result = tokio::task::spawn_blocking(move || { + let blocking_client = reqwest::blocking::Client::new(); + rpc.send_json_blocking::(&blocking_client, &estimate_req) + }) + .await + .map_err(|e| ErrorObjectOwned::owned(-1, format!("Task join error: {}", e), None::))?; + + match result { + Ok(res) => { + if let Some(fee_rate) = res["feerate"].as_f64() { + let fee_rate_sat_vb = (fee_rate * 100_000.0).ceil() as u64; + let blocks = res["blocks"].as_u64().unwrap_or(conf_target as u64); + Ok(FeeEstimateResponse { + feerate_sat_vb: fee_rate_sat_vb, + blocks, + }) + } else { + Err(ErrorObjectOwned::owned( + -1, + "Fee estimation unavailable: no feerate in response".to_string(), + None::, + )) + } + } + Err(e) => Err(ErrorObjectOwned::owned( + -1, + format!("RPC error: {}", e), + None::, + )), + } + } + async fn debug_set_expire_height(&self, space: &str, expire_height: u32) -> Result<(), ErrorObjectOwned> { // Only allow on regtest let info = self.store.get_server_info().await From 2385bae59775bb53324f245337a19e3b55647f15 Mon Sep 17 00:00:00 2001 From: spacesops Date: Sun, 12 Apr 2026 17:40:02 -0400 Subject: [PATCH 15/20] feat: handle-based getfallback via Subject::Handle and block index scan - Add Subject::Handle(SName) with FromStr: multi-label names (e.g. sub@space) parse as handles; single-label SNames map to Label for backward compatibility. - Reject handles in wallet/RPC paths that require a concrete space or num (setfallback, transfer, operate, delegate, etc.). - Implement get_fallback for handles: scan merged spaces/nums block index for SIP-7 payloads with TXT key "handle" matching the canonical handle string; return the latest match by height and tx position when available. - Add SqliteIndex list_*_blocks_merged, Chain helpers, and ChainStateCommand::FindFallbackByHandle. - Update rpc schema docs for getfallback; add unit tests for parsing and scan. Made-with: Cursor --- client/src/fallback_handle.rs | 224 ++++++++++++++++++++++++++++++++++ client/src/lib.rs | 1 + client/src/rpc.rs | 35 +++++- client/src/rpc_schema.rs | 7 +- client/src/store/chain.rs | 21 ++++ client/src/store/index.rs | 67 +++++++++- client/src/wallets.rs | 28 +++++ wallet/src/lib.rs | 92 ++++++++++++-- 8 files changed, 459 insertions(+), 16 deletions(-) create mode 100644 client/src/fallback_handle.rs diff --git a/client/src/fallback_handle.rs b/client/src/fallback_handle.rs new file mode 100644 index 0000000..749b3d7 --- /dev/null +++ b/client/src/fallback_handle.rs @@ -0,0 +1,224 @@ +//! Resolve SIP-7 fallback payloads by scanning indexed block metadata for TXT `handle=…`. + +use spaces_nums::TxChangeSet as NumTxChangeSet; +use spaces_protocol::bitcoin::BlockHash; +use spaces_protocol::{validate::TxChangeSet as SpaceTxChangeSet, Covenant}; +use sip7::Record; + +use crate::client::{BlockMeta, NumBlockMeta}; +use crate::store::chain::Chain; + +/// True if the SIP-7 payload parses and contains a TXT record with key `handle` whose value +/// equals `needle` (exact UTF-8; any chunk in a multi-value TXT matches). +pub fn sip7_handle_matches(data: &[u8], needle: &str) -> bool { + let rs = sip7::RecordSet::new(data.to_vec()); + let Ok(records) = rs.unpack() else { + return false; + }; + for r in records { + if let Record::Txt { key, value } = r { + if key == "handle" && value.iter().any(|v| v == needle) { + return true; + } + } + } + false +} + +fn transfer_data_from_space_changeset(changeset: &SpaceTxChangeSet) -> Vec> { + let mut out = Vec::new(); + for c in &changeset.creates { + if let Some(space) = &c.space { + if let Covenant::Transfer { data: Some(b), .. } = &space.covenant { + out.push(b.clone().to_vec()); + } + } + } + for u in &changeset.updates { + if let Some(space) = &u.output.spaceout.space { + if let Covenant::Transfer { data: Some(b), .. } = &space.covenant { + out.push(b.clone().to_vec()); + } + } + } + out +} + +fn data_from_num_changeset(changeset: &NumTxChangeSet) -> Vec> { + changeset + .creates + .iter() + .filter_map(|n| n.num.data.as_ref().map(|b| b.clone().to_vec())) + .collect() +} + +fn space_events_for_block(meta: &BlockMeta) -> Vec<(u32, Vec)> { + let mut out = Vec::new(); + for tx in &meta.tx_meta { + let pos = tx.tx.as_ref().map(|t| t.position).unwrap_or(u32::MAX); + for pl in transfer_data_from_space_changeset(&tx.changeset) { + out.push((pos, pl)); + } + } + out +} + +fn num_events_for_block(meta: &NumBlockMeta) -> Vec<(u32, Vec)> { + let mut out = Vec::new(); + for tx in &meta.tx_meta { + let pos = tx.tx.as_ref().map(|t| t.position).unwrap_or(u32::MAX); + for pl in data_from_num_changeset(&tx.changeset) { + out.push((pos, pl)); + } + } + out +} + +/// Latest matching raw SIP-7 bytes in chain order (height, then block tx index when known). +pub(crate) fn merge_scan_fallback( + spaces_rows: &[(u32, BlockHash, BlockMeta)], + nums_rows: &[(u32, BlockHash, NumBlockMeta)], + needle: &str, +) -> Option> { + let mut i = 0usize; + let mut j = 0usize; + let mut best: Option<(u32, u32, Vec)> = None; + + loop { + let h_sp = spaces_rows.get(i).map(|r| r.0); + let h_nm = nums_rows.get(j).map(|r| r.0); + let h = match (h_sp, h_nm) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => break, + }; + + let mut events = Vec::new(); + if h_sp == Some(h) { + events.extend(space_events_for_block(&spaces_rows[i].2)); + i += 1; + } + if h_nm == Some(h) { + events.extend(num_events_for_block(&nums_rows[j].2)); + j += 1; + } + + events.sort_by_key(|(pos, _)| *pos); + for (pos, pl) in events { + if sip7_handle_matches(&pl, needle) { + best = Some((h, pos, pl)); + } + } + } + + best.map(|(_, _, v)| v) +} + +/// Latest matching raw SIP-7 bytes in chain order (height, then block tx index when known). +pub fn find_fallback_payload_by_handle(chain: &Chain, needle: &str) -> anyhow::Result>> { + if !chain.has_spaces_index() { + anyhow::bail!( + "handle lookup requires the spaces block index; run spaced with --block-index" + ); + } + + let spaces_rows = chain.list_spaces_blocks_merged()?; + let nums_rows = if chain.has_nums_index() { + chain.list_nums_blocks_merged()? + } else { + Vec::new() + }; + + Ok(merge_scan_fallback(&spaces_rows, &nums_rows, needle)) +} + +#[cfg(test)] +mod tests { + use super::*; + use spaces_protocol::bitcoin::hashes::Hash as _; + use spaces_protocol::bitcoin::{Amount, BlockHash, ScriptBuf, Txid}; + use spaces_protocol::slabel::SLabel; + use spaces_protocol::validate::TxChangeSet; + use spaces_protocol::{Bytes, Space, SpaceOut}; + use std::str::FromStr as _; + + use crate::client::{BlockMeta, TxData, TxEntry}; + + fn sample_payload(handle: &str) -> Vec { + sip7::RecordSet::pack(vec![ + sip7::Record::seq(1), + sip7::Record::txt("handle", &[handle]), + ]) + .expect("pack") + .to_bytes() + } + + fn tx_entry_with_payload(height: u32, tx_pos: u32, payload: Vec) -> TxEntry { + let space_out = SpaceOut { + n: 0, + space: Some(Space { + name: SLabel::from_str("@test").expect("label"), + covenant: Covenant::Transfer { + expire_height: height + 1000, + data: Some(Bytes::new(payload)), + }, + }), + value: Amount::ZERO, + script_pubkey: ScriptBuf::new(), + }; + let cs = TxChangeSet { + txid: Txid::all_zeros(), + spends: vec![], + creates: vec![space_out], + updates: vec![], + }; + TxEntry { + changeset: cs, + tx: Some(TxData { + position: tx_pos, + raw: Bytes::new(Vec::new()), + }), + } + } + + #[test] + fn sip7_handle_matches_respects_key_and_value() { + let pl = sample_payload("dictionary@mad"); + assert!(sip7_handle_matches(&pl, "dictionary@mad")); + assert!(!sip7_handle_matches(&pl, "other@mad")); + } + + #[test] + fn merge_scan_picks_latest_height() { + let h = BlockHash::all_zeros(); + let want = sample_payload("dictionary@mad"); + let older = BlockMeta { + height: 10, + tx_meta: vec![tx_entry_with_payload(10, 0, want.clone())], + }; + let newer = BlockMeta { + height: 20, + tx_meta: vec![tx_entry_with_payload(20, 0, want.clone())], + }; + let rows = vec![(10, h, older), (20, h, newer)]; + let got = merge_scan_fallback(&rows, &[], "dictionary@mad").expect("match"); + assert_eq!(got, want); + } + + #[test] + fn merge_scan_picks_later_tx_position_same_block() { + let h = BlockHash::all_zeros(); + let want = sample_payload("dictionary@mad"); + let meta = BlockMeta { + height: 10, + tx_meta: vec![ + tx_entry_with_payload(10, 1, sample_payload("wrong@x")), + tx_entry_with_payload(10, 3, want.clone()), + ], + }; + let rows = vec![(10, h, meta)]; + let got = merge_scan_fallback(&rows, &[], "dictionary@mad").expect("match"); + assert_eq!(got, want); + } +} diff --git a/client/src/lib.rs b/client/src/lib.rs index 658636d..4dcd1f5 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -15,6 +15,7 @@ mod checker; pub mod client; pub mod config; pub mod format; +mod fallback_handle; pub mod rpc; #[cfg(feature = "schema")] pub mod rpc_schema; diff --git a/client/src/rpc.rs b/client/src/rpc.rs index f8b2870..0805291 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -173,6 +173,10 @@ pub enum ChainStateCommand { txid: Txid, resp: Responder>>, }, + FindFallbackByHandle { + needle: String, + resp: Responder>>>, + }, GetBlockMeta { height_or_hash: HeightOrHash, resp: Responder>, @@ -1532,6 +1536,13 @@ impl RpcServer for RpcServerImpl { async fn get_fallback(&self, subject: Subject) -> Result, ErrorObjectOwned> { let data = match &subject { + Subject::Handle(name) => { + let needle = name.to_string(); + self.store + .find_fallback_by_handle(&needle) + .await + .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::))? + } Subject::Label(label) if !label.is_numeric() => { let space_hash = SpaceKey::from(Sha256::hash(label.as_ref())); let fso = self.store.get_space(space_hash).await @@ -1546,7 +1557,7 @@ impl RpcServer for RpcServerImpl { }) } _ => { - let fpt = self.store.get_ptr(subject).await + let fpt = self.store.get_ptr(subject.clone()).await .map_err(|e| ErrorObjectOwned::owned(-1, e.to_string(), None::))?; fpt.and_then(|fpt| fpt.numout.num.data.map(|b| b.to_vec())) } @@ -1854,6 +1865,10 @@ impl AsyncChainState { let res = Self::get_indexed_tx(state, &txid, client, rpc).await; let _ = resp.send(res); } + ChainStateCommand::FindFallbackByHandle { needle, resp } => { + let res = state.find_fallback_payload_by_handle(&needle); + let _ = resp.send(res); + } ChainStateCommand::EstimateBid { target, resp } => { let estimate = state.estimate_bid(target); _ = resp.send(estimate); @@ -2367,6 +2382,17 @@ impl AsyncChainState { .await?; resp_rx.await? } + + pub async fn find_fallback_by_handle(&self, needle: &str) -> anyhow::Result>> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::FindFallbackByHandle { + needle: needle.to_string(), + resp, + }) + .await?; + resp_rx.await? + } } fn resolve_num_id(state: &mut Chain, subject: &Subject) -> anyhow::Result { @@ -2378,6 +2404,7 @@ fn resolve_num_id(state: &mut Chain, subject: &Subject) -> anyhow::Result .ok_or_else(|| anyhow!("numeric '{}' not found", numeric)) } Subject::Label(_) => Err(anyhow!("expected a num id or numeric, not a space")), + Subject::Handle(h) => Err(anyhow!("expected a num id or numeric, not a handle: {}", h)), } } @@ -2449,6 +2476,7 @@ fn resolve_label(state: &mut Chain, subject: &Subject) -> anyhow::Result .ok_or_else(|| anyhow!("num id '{}' not found", id))?; Ok(info.numout.num.name.to_slabel()) } + Subject::Handle(h) => Err(anyhow!("expected a space or num, not a handle: {}", h)), } } @@ -2473,7 +2501,10 @@ fn get_delegation(state: &mut Chain, subject: &Subject) -> anyhow::Result