From 9347c2d4981920f350a57ea87828c28b1c2a7f22 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Fri, 10 Oct 2025 23:24:25 +0200 Subject: [PATCH 01/11] 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/11] 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/11] 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 aa6fd0312fea3b5764b50e219718c5bf6c084a90 Mon Sep 17 00:00:00 2001 From: spacesops Date: Wed, 3 Dec 2025 15:53:24 -0500 Subject: [PATCH 04/11] CreatePtr Data Parsing --- SUBSPACES.md | 6 + client/src/bin/space-cli.rs | 81 +++++++++++++- client/src/format.rs | 44 +++++++- client/src/rpc.rs | 60 ++++++++++ client/src/store/chain.rs | 8 ++ client/src/store/ptrs.rs | 73 +++++++++++- client/src/store/spaces.rs | 75 +++++++++++++ client/src/wallets.rs | 6 +- ptr/src/lib.rs | 2 + ptr/src/vtlv.rs | 214 ++++++++++++++++++++++++++++++++++++ wallet/src/builder.rs | 5 +- 11 files changed, 567 insertions(+), 7 deletions(-) create mode 100644 ptr/src/vtlv.rs diff --git a/SUBSPACES.md b/SUBSPACES.md index ac53a60..5336801 100644 --- a/SUBSPACES.md +++ b/SUBSPACES.md @@ -86,6 +86,12 @@ You can create an on-chain identifier that only the controller of the script pub $ space-cli createptr 5120d3c3196cb3ed7fa79c882ed62f8e5942e546130d5ae5983da67dbb6c9bdd2e79 ``` +Optionally, you can set hex-encoded data on the created pointer using the `--data` parameter: + +```bash +$ space-cli createptr 5120d3c3196cb3ed7fa79c882ed62f8e5942e546130d5ae5983da67dbb6c9bdd2e79 --data deadbeef +``` + This command creates a UTXO with the same script pubkey and "mints" a space pointer (sptr) derived from it: ``` diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index add3cc6..1f3d3ea 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -24,7 +24,7 @@ use spaces_client::{ config::{default_cookie_path, default_spaces_rpc_port, ExtendedNetwork}, deserialize_base64, format::{ - print_error_rpc_response, print_list_bidouts, print_list_spaces_response, + print_error_rpc_response, print_list_all_spaces, print_list_bidouts, print_list_spaces_response, print_list_transactions, print_list_unspent, print_list_wallets, print_server_info, print_wallet_balance_response, print_wallet_info, print_wallet_response, Format, }, @@ -157,6 +157,10 @@ enum Commands { /// The script public key as hex string spk: String, + /// Hex encoded data to set on the created ptr + #[arg(long)] + data: Option, + #[arg(long, short)] fee_rate: Option, }, @@ -166,6 +170,13 @@ enum Commands { /// The sha256 hash of the spk or the spk itself prefixed with hex: spk: String, }, + /// Get all ptrs info (same output format as getptr) + #[command(name = "getallptrs")] + GetAllPtrs { + /// Only return PTRs with non-null data + #[arg(long)] + with_data: bool, + }, /// Transfer ownership of spaces and/or PTRs to the given name or address #[command( name = "transfer", @@ -425,6 +436,9 @@ enum Commands { /// still in auction with a winning bid #[command(name = "listspaces")] ListSpaces, + /// List all spaces in the chain state (not just wallet-related) + #[command(name = "listallspaces")] + ListAllSpaces, /// List unspent auction outputs i.e. outputs that can be /// auctioned off in the bidding process #[command(name = "listbidouts")] @@ -672,6 +686,40 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +fn parse_ptr_for_json(ptr: &spaces_ptr::FullPtrOut) -> serde_json::Value { + use spaces_ptr::vtlv; + + let mut ptr_json = serde_json::to_value(ptr).expect("ptr should be serializable"); + + // Since ptrout and sptr are flattened via serde(flatten), the data field + // appears directly in the JSON object, not nested. Look for "data" at the top level. + if let Some(obj) = ptr_json.as_object_mut() { + if let Some(data) = obj.remove("data") { + // Bytes serializes as hex string in JSON + if let Some(hex_str) = data.as_str() { + if let Ok(data_bytes) = hex::decode(hex_str) { + match vtlv::parse_vtlv(&data_bytes) { + Ok(parsed) => { + obj.insert("parsed".to_string(), serde_json::to_value(parsed).expect("parsed should be serializable")); + } + Err(_) => { + // If parsing fails, keep the original data + obj.insert("data".to_string(), data); + } + } + } else { + obj.insert("data".to_string(), data); + } + } else { + // Not a string, keep as-is + obj.insert("data".to_string(), data); + } + } + } + + ptr_json +} + async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), ClientError> { match command { Commands::GetRollout { @@ -894,6 +942,9 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client .await?; } else { // TODO: support set data for spaces + return Err(ClientError::Custom(format!( + "setrawfallback: setting data for spaces is not yet supported. Use an SPTR (sptr1...) instead of a space name." + ))); // // Space fallback: use existing space script // let space = normalize_space(&space_or_sptr); // let space_script = @@ -931,6 +982,11 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client let spaces = cli.client.wallet_list_spaces(&cli.wallet).await?; print_list_spaces_response(tip.tip.height, spaces, cli.format); } + Commands::ListAllSpaces => { + let tip = cli.client.get_server_info().await?; + let spaces = cli.client.get_all_spaces().await?; + print_list_all_spaces(tip.tip.height, spaces, cli.format); + } Commands::Balance => { let balance = cli.client.wallet_get_balance(&cli.wallet).await?; print_wallet_balance_response(balance, cli.format); @@ -1100,15 +1156,25 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client println!("{}", serde_json::to_string(&event).expect("result")); } - Commands::CreatePtr { spk, fee_rate } => { + Commands::CreatePtr { spk, data, fee_rate } => { let spk = ScriptBuf::from(hex::decode(spk) .map_err(|_| ClientError::Custom("Invalid spk hex".to_string()))?); + let data = match data { + Some(data_hex) => { + Some(hex::decode(data_hex).map_err(|e| { + ClientError::Custom(format!("Could not hex decode data: {}", e)) + })?) + } + None => None, + }; + let sptr = Sptr::from_spk::(spk.clone()); println!("Creating sptr: {}", sptr); cli.send_request( Some(RpcWalletRequest::CreatePtr(CreatePtrParams { spk: hex::encode(spk.as_bytes()), + data, })), None, fee_rate, @@ -1127,6 +1193,17 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client .map_err(|e| ClientError::Custom(e.to_string()))?; println!("{}", serde_json::to_string(&ptr).expect("result")); } + Commands::GetAllPtrs { with_data } => { + let ptrs = cli + .client + .get_all_ptrs(with_data) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + let parsed_ptrs: Vec = ptrs.iter() + .map(|ptr| parse_ptr_for_json(ptr)) + .collect(); + println!("{}", serde_json::to_string(&parsed_ptrs).expect("result")); + } Commands::GetPtrOut { outpoint } => { let ptrout = cli diff --git a/client/src/format.rs b/client/src/format.rs index ceb52eb..4ffdb24 100644 --- a/client/src/format.rs +++ b/client/src/format.rs @@ -3,7 +3,7 @@ use colored::{Color, Colorize}; use jsonrpsee::core::Serialize; use serde::Deserialize; use spaces_protocol::{ - bitcoin::{Amount, Network, OutPoint}, Covenant + bitcoin::{Amount, Network, OutPoint}, Covenant, FullSpaceOut }; use spaces_wallet::{ address::SpaceAddress, @@ -124,6 +124,48 @@ pub fn print_list_bidouts(bidouts: Vec, format: Format) { } } +pub fn print_list_all_spaces( + _current_block: u32, + spaces: Vec, + format: Format, +) { + match format { + Format::Text => { + #[derive(Tabled)] + struct AllSpaces { + space: String, + status: String, + value: String, + txid: String, + } + + let mut table_data = Vec::new(); + for space_out in spaces { + let space = space_out.spaceout.space.as_ref(); + let space_name = space.map(|s| s.name.to_string()).unwrap_or_else(|| "unknown".to_string()); + // All spaces returned are owned (filtered in get_all_spaces) + table_data.push(AllSpaces { + space: space_name, + status: "OWNED".to_string(), + value: format!("{} sats", space_out.spaceout.value.to_sat()), + txid: format!("{}", space_out.txid), + }); + } + + if table_data.is_empty() { + println!("No spaces found."); + } else { + println!("All Spaces ({} total):", table_data.len()); + let table = Table::new(table_data); + println!("{}", table); + } + } + Format::Json => { + println!("{}", serde_json::to_string_pretty(&spaces).unwrap()); + } + } +} + pub fn print_list_transactions(txs: Vec, format: Format) { match format { Format::Text => { diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 42983f1..082eeb3 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -169,6 +169,13 @@ pub enum ChainStateCommand { outpoint: OutPoint, resp: Responder>>, }, + GetAllSpaces { + resp: Responder>>, + }, + GetAllPtrs { + with_data: bool, + resp: Responder>>, + }, GetTxMeta { txid: Txid, resp: Responder>>, @@ -277,6 +284,12 @@ pub trait Rpc { #[method(name = "getdelegator")] async fn get_delegator(&self, sptr: Sptr) -> Result, ErrorObjectOwned>; + #[method(name = "getallspaces")] + async fn get_all_spaces(&self) -> Result, ErrorObjectOwned>; + + #[method(name = "getallptrs")] + async fn get_all_ptrs(&self, with_data: bool) -> Result, ErrorObjectOwned>; + #[method(name = "checkpackage")] async fn check_package( &self, @@ -546,6 +559,8 @@ pub struct TransferSpacesParams { #[derive(Clone, Serialize, Deserialize)] pub struct CreatePtrParams { pub spk: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option>, } #[derive(Clone, Serialize, Deserialize)] @@ -1075,6 +1090,19 @@ impl RpcServer for RpcServerImpl { Ok(delegator) } + async fn get_all_spaces(&self) -> Result, ErrorObjectOwned> { + let spaces = self.store.get_all_spaces() + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::))?; + Ok(spaces) + } + + async fn get_all_ptrs(&self, with_data: bool) -> Result, ErrorObjectOwned> { + let ptrs = self.store.get_all_ptrs(with_data) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::))?; + Ok(ptrs) + } async fn check_package( &self, @@ -1630,6 +1658,14 @@ impl AsyncChainState { .context("could not fetch ptrouts"); let _ = resp.send(result); } + ChainStateCommand::GetAllSpaces { resp } => { + let result = get_all_spaces(state); + let _ = resp.send(result); + } + ChainStateCommand::GetAllPtrs { with_data, resp } => { + let result = get_all_ptrs(state, with_data); + let _ = resp.send(result); + } ChainStateCommand::GetBlockMeta { height_or_hash, resp, @@ -2198,6 +2234,22 @@ impl AsyncChainState { resp_rx.await? } + pub async fn get_all_spaces(&self) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetAllSpaces { resp }) + .await?; + resp_rx.await? + } + + pub async fn get_all_ptrs(&self, with_data: bool) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetAllPtrs { with_data, resp }) + .await?; + resp_rx.await? + } + pub async fn get_block_meta( &self, height_or_hash: HeightOrHash, @@ -2310,6 +2362,14 @@ fn get_delegation(state: &mut Chain, space: SLabel) -> anyhow::Result anyhow::Result> { + state.get_all_spaces() +} + +fn get_all_ptrs(state: &mut Chain, with_data: bool) -> anyhow::Result> { + state.get_all_ptrs(with_data) +} + fn get_commitment(state: &mut Chain, space: SLabel, root: Option) -> anyhow::Result> { let root = match root { None => { diff --git a/client/src/store/chain.rs b/client/src/store/chain.rs index 51694b6..9c9229b 100644 --- a/client/src/store/chain.rs +++ b/client/src/store/chain.rs @@ -91,10 +91,18 @@ impl Chain { self.db.sp.state.get_space_info(space_hash) } + pub fn get_all_spaces(&mut self) -> anyhow::Result> { + self.db.sp.state.get_all_spaces() + } + pub fn get_ptr_info(&mut self, key: &Sptr) -> anyhow::Result> { self.db.pt.state.get_ptr_info(key) } + pub fn get_all_ptrs(&mut self, with_data: bool) -> anyhow::Result> { + self.db.pt.state.get_all_ptrs(with_data) + } + pub fn load(_network: Network, genesis: ChainAnchor, ptrs_genesis: ChainAnchor, dir: &Path, index_spaces: bool, index_ptrs: bool) -> anyhow::Result { let proto_db_path = dir.join("protocol.sdb"); let ptrs_db_path = dir.join("ptrs.sdb"); diff --git a/client/src/store/ptrs.rs b/client/src/store/ptrs.rs index a0a1c52..5f8c75c 100644 --- a/client/src/store/ptrs.rs +++ b/client/src/store/ptrs.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap}, + collections::{BTreeMap, BTreeSet}, fs::OpenOptions, io, io::ErrorKind, @@ -121,6 +121,8 @@ pub trait PtrChainState { &mut self, space_hash: &Sptr, ) -> Result>; + + fn get_all_ptrs(&mut self, with_data: bool) -> Result>; } impl PtrChainState for PtrLiveSnapshot { @@ -157,6 +159,75 @@ impl PtrChainState for PtrLiveSnapshot { } Ok(None) } + + fn get_all_ptrs(&mut self, with_data: bool) -> Result> { + let mut ptrs = Vec::new(); + let mut seen_keys = BTreeSet::new(); + + // First, collect staged changes (memory) - collect Sptr keys first, then process + let mut staged_sptrs = Vec::new(); + { + let rlock = self.staged.read().expect("acquire lock"); + for (key, value) in rlock.memory.iter() { + // Skip deleted entries + if value.is_none() { + continue; + } + + // Try to decode key as Sptr (32 bytes) - Hash is already [u8; 32] + // Use unsafe transmute since Hash and Sptr are both [u8; 32] + let sptr = unsafe { std::mem::transmute::(*key) }; + + // Check if value decodes as EncodableOutpoint (indicates it's a PTR entry) + if let Some(value) = value { + let decode_result: Result<(EncodableOutpoint, usize), _> = bincode::decode_from_slice(&value, config::standard()); + if decode_result.is_ok() { + seen_keys.insert(*key); + staged_sptrs.push(sptr); + } + } + } + } + + // Now process staged SPTRs (lock is dropped) + for sptr in staged_sptrs { + if let Ok(Some(ptr_out)) = self.get_ptr_info(&sptr) { + // Filter by with_data flag if set + if !with_data || (ptr_out.ptrout.sptr.is_some() && ptr_out.ptrout.sptr.as_ref().unwrap().data.is_some()) { + ptrs.push(ptr_out); + } + } + } + + // Then iterate through snapshot + let snapshot = self.inner()?; + for item in snapshot.iter() { + let (key, value) = item?; + + // Skip if already processed from staged changes + if seen_keys.contains(&key) { + continue; + } + + // Try to decode key as Sptr (32 bytes) - Hash is already [u8; 32] + // Use unsafe transmute since Hash and Sptr are both [u8; 32] + let sptr = unsafe { std::mem::transmute::(key) }; + + // Check if value decodes as EncodableOutpoint (indicates it's a PTR entry) + let decode_result: Result<(EncodableOutpoint, usize), _> = bincode::decode_from_slice(&value, config::standard()); + if decode_result.is_ok() { + // Try to get ptr info - if successful, it's a valid PTR + if let Ok(Some(ptr_out)) = self.get_ptr_info(&sptr) { + // Filter by with_data flag if set + if !with_data || (ptr_out.ptrout.sptr.is_some() && ptr_out.ptrout.sptr.as_ref().unwrap().data.is_some()) { + ptrs.push(ptr_out); + } + } + } + } + + Ok(ptrs) + } } impl PtrLiveSnapshot { diff --git a/client/src/store/spaces.rs b/client/src/store/spaces.rs index dc27c9a..23d99c7 100644 --- a/client/src/store/spaces.rs +++ b/client/src/store/spaces.rs @@ -174,6 +174,8 @@ pub trait SpacesState { &mut self, space_hash: &spaces_protocol::hasher::SpaceKey, ) -> anyhow::Result>; + + fn get_all_spaces(&mut self) -> anyhow::Result>; } impl SpacesState for SpLiveSnapshot { @@ -205,6 +207,79 @@ impl SpacesState for SpLiveSnapshot { } Ok(None) } + + fn get_all_spaces(&mut self) -> anyhow::Result> { + let mut spaces = Vec::new(); + let mut seen_keys = BTreeSet::new(); + + // First, collect staged changes (memory) - collect outpoints first, then process + let mut staged_outpoints = Vec::new(); + { + let rlock = self.staged.read().expect("acquire lock"); + for (key, value) in rlock.memory.iter() { + if SpaceKey::is_valid(key) { + // Skip deleted entries + if value.is_none() { + continue; + } + + if let Some(value) = value { + let decode_result: Result<(EncodableOutpoint, usize), _> = bincode::decode_from_slice(&value, config::standard()); + if let Ok((outpoint_enc, _)) = decode_result { + seen_keys.insert(*key); + let outpoint: OutPoint = outpoint_enc.into(); + staged_outpoints.push(outpoint); + } + } + } + } + } + + // Now process staged outpoints (lock is dropped) + for outpoint in staged_outpoints { + if let Ok(Some(spaceout)) = self.get_spaceout(&outpoint) { + // Only include owned spaces + if spaceout.space.as_ref().map(|s| s.is_owned()).unwrap_or(false) { + spaces.push(FullSpaceOut { + txid: outpoint.txid, + spaceout, + }); + } + } + } + + // Then iterate through snapshot + let snapshot = self.inner()?; + for item in snapshot.iter() { + let (key, value) = item?; + + // Only process SpaceKey entries (not BidKey or OutpointKey) + if SpaceKey::is_valid(&key) { + // Skip if already processed from staged changes + if seen_keys.contains(&key) { + continue; + } + + // Decode the value as EncodableOutpoint + let decode_result: Result<(EncodableOutpoint, usize), _> = bincode::decode_from_slice(&value, config::standard()); + if let Ok((outpoint_enc, _)) = decode_result { + let outpoint: OutPoint = outpoint_enc.into(); + // Get the spaceout for this outpoint + if let Ok(Some(spaceout)) = self.get_spaceout(&outpoint) { + // Only include owned spaces + if spaceout.space.as_ref().map(|s| s.is_owned()).unwrap_or(false) { + spaces.push(FullSpaceOut { + txid: outpoint.txid, + spaceout, + }); + } + } + } + } + } + + Ok(spaces) + } } impl SpLiveSnapshot { diff --git a/client/src/wallets.rs b/client/src/wallets.rs index 7fd0829..b4e4b2f 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -1295,7 +1295,11 @@ impl RpcWallet { builder = builder.add_ptr(PtrRequest { spk, - }) + }); + + if let Some(data) = params.data { + builder = builder.add_data(data); + } } RpcWalletRequest::Commit(params) => { let reqs = commit_params_to_req(chain, wallet, params)?; diff --git a/ptr/src/lib.rs b/ptr/src/lib.rs index 7d47965..840d43c 100644 --- a/ptr/src/lib.rs +++ b/ptr/src/lib.rs @@ -1,6 +1,8 @@ #[cfg(feature = "std")] pub mod sptr; pub mod constants; +#[cfg(feature = "serde")] +pub mod vtlv; #[cfg(feature = "bincode")] use bincode::{Decode, Encode}; diff --git a/ptr/src/vtlv.rs b/ptr/src/vtlv.rs new file mode 100644 index 0000000..dcd0be6 --- /dev/null +++ b/ptr/src/vtlv.rs @@ -0,0 +1,214 @@ +//! VTLV (Version-Type-Length-Value) parser implementation +//! Based on SCHEMA.md from spaces-hex-tool + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +pub struct VtlvRecord { + pub version: u8, + pub r#type: u8, + pub length: u16, + pub value: Vec, +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +pub struct ParsedData { + pub version: u8, + pub records: Vec, +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +pub struct ParsedRecord { + pub r#type: u8, + pub name: String, + pub value: ParsedValue, +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ParsedValue { + String(String), + Hex(String), + Bytes(Vec), +} + +/// Parse VTLV data according to SCHEMA.md +pub fn parse_vtlv(data: &[u8]) -> Result { + if data.is_empty() { + return Err("Empty data".to_string()); + } + + let mut offset = 0; + let version = data[offset]; + offset += 1; + + let mut records = Vec::new(); + + if version == 0x00 { + // Version 0x00: Length (2 bytes) + Value (N bytes) + if data.len() < 3 { + return Err("Insufficient data for version 0x00".to_string()); + } + let length = u16::from_be_bytes([data[offset], data[offset + 1]]); + offset += 2; + if offset + length as usize > data.len() { + return Err("Length exceeds data size".to_string()); + } + let value = data[offset..offset + length as usize].to_vec(); + records.push(ParsedRecord { + r#type: 0x00, + name: "Data".to_string(), + value: ParsedValue::Hex(hex::encode(&value)), + }); + } else { + // Version > 0x01: Repeated Type (1 byte) + Length (1 byte) + Value (N bytes) + while offset < data.len() { + if offset + 2 > data.len() { + break; // Need at least Type + Length + } + + let r#type = data[offset]; + offset += 1; + let length = data[offset] as usize; + offset += 1; + + if offset + length > data.len() { + break; // Not enough data + } + + let value_bytes = &data[offset..offset + length]; + offset += length; + + let name = type_to_name(r#type); + let value = parse_value(r#type, value_bytes); + + records.push(ParsedRecord { + r#type, + name, + value, + }); + } + } + + Ok(ParsedData { version, records }) +} + +fn type_to_name(r#type: u8) -> String { + match r#type { + 0x00 => "Handle".to_string(), + 0x01 => "Owner URI".to_string(), + 0x02 => "Nostr Pubkey".to_string(), + 0x03 => "Nostr Relay".to_string(), + 0x04 => "Pubky.app Pubkey".to_string(), + 0x05 => "Decentralized ID".to_string(), + 0x06 => "DNS A Record".to_string(), + 0x07 => "DNS CNAME".to_string(), + 0x08 => "DNS SMTP".to_string(), + 0x09 => "DNS TXT".to_string(), + 0x0A => "Bitcoin Address".to_string(), + 0x0B => "Ethereum Address".to_string(), + _ => format!("Reserved (0x{:02X})", r#type), + } +} + +fn parse_value(r#type: u8, value_bytes: &[u8]) -> ParsedValue { + match r#type { + 0x00 => { + // Handle: Space handle identifier - try to parse as UTF-8 + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + 0x01 => { + // Owner URI: RPC Interface or Info Website - UTF-8 string + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + 0x02 => { + // Nostr Pubkey: 64 hex digits (32 bytes) + ParsedValue::Hex(hex::encode(value_bytes)) + } + 0x03 => { + // Nostr Relay: WebSocket relay - UTF-8 string + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + 0x04 => { + // Pubky.app Pubkey: UTF-8 string + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + 0x05 => { + // Decentralized ID: DID identifier (68 bytes hex) - UTF-8 string + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + 0x06 => { + // DNS A Record: IPv4/IPv6 address as hex + ParsedValue::Hex(hex::encode(value_bytes)) + } + 0x07 => { + // DNS CNAME: Canonical name - UTF-8 string + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + 0x08 => { + // DNS SMTP: SMTP server address - UTF-8 string + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + 0x09 => { + // DNS TXT: Arbitrary ASCII text - UTF-8 string + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + 0x0A => { + // Bitcoin Address: UTF-8 string + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + 0x0B => { + // Ethereum Address: UTF-8 string + String::from_utf8(value_bytes.to_vec()) + .map(ParsedValue::String) + .unwrap_or_else(|_| ParsedValue::Hex(hex::encode(value_bytes))) + } + _ => { + // Unknown type: return as hex + ParsedValue::Hex(hex::encode(value_bytes)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_version_00() { + let data = vec![0x00, 0x00, 0x05, 0x48, 0x65, 0x6C, 0x6C, 0x6F]; // Version 0x00, Length 5, "Hello" + let result = parse_vtlv(&data).unwrap(); + assert_eq!(result.version, 0x00); + assert_eq!(result.records.len(), 1); + } + + #[test] + fn test_parse_version_01() { + // Version 0x01, Type 0x02 (Nostr Pubkey), Length 0x20, 32 bytes + let mut data = vec![0x01, 0x02, 0x20]; + data.extend_from_slice(&[0u8; 32]); + let result = parse_vtlv(&data).unwrap(); + assert_eq!(result.version, 0x01); + assert_eq!(result.records.len(), 1); + assert_eq!(result.records[0].r#type, 0x02); + } +} + diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index 77b2874..8e6cfda 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -939,6 +939,7 @@ impl Builder { } let has_transfers = !params.transfers.is_empty(); + let has_binds = !params.binds.is_empty(); // Handle transfers: for transfer in params.transfers { @@ -962,9 +963,9 @@ impl Builder { ); } - // Add data OP_RETURN if present (only makes sense with transfers) + // Add data OP_RETURN if present (works with transfers or binds) if let Some(data) = params.data { - if has_transfers { + if has_transfers || has_binds { let script = create_data_script(&data); builder.add_recipient(script, Amount::from_sat(0)); } From 38a58382fcfa6c6f1f49c09987b0767b22c63ce0 Mon Sep 17 00:00:00 2001 From: spacesops Date: Tue, 9 Dec 2025 16:29:16 -0500 Subject: [PATCH 05/11] Enhance space-cli with data parsing, memo support, and fixes - Add parse_space_for_json to parse covenant data into records format (like getallptrs) - Fix authorize command to properly parse @space names using from_str_unprefixed - Add --memo option to send command with OP_RETURN output (max 80 chars, hex-encoded) - Add --with-memos flag to listtransactions to display decoded memo text from OP_RETURN outputs - Fix ResolvableTarget parsing to use from_str_unprefixed for space names --- client/src/bin/space-cli.rs | 97 +++++++++++++++++++++++++++++++++++-- client/src/format.rs | 22 ++++++++- client/src/rpc.rs | 5 +- client/src/wallets.rs | 45 +++++++++++++++-- 4 files changed, 157 insertions(+), 12 deletions(-) diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 1f3d3ea..a699fc9 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -275,7 +275,7 @@ enum Commands { /// Send the specified amount of BTC to the given name or address #[command( name = "send", - override_usage = "space-cli send --to " + override_usage = "space-cli send --to [--memo ]" )] SendCoins { /// Amount to send in satoshi @@ -284,6 +284,9 @@ enum Commands { /// Recipient space name or address #[arg(long, display_order = 1)] to: String, + /// Optional memo text (max 80 characters) to include as OP_RETURN output + #[arg(long, display_order = 2)] + memo: Option, /// Fee rate to use in sat/vB #[arg(long, short)] fee_rate: Option, @@ -431,6 +434,9 @@ enum Commands { count: usize, #[arg(default_value = "0")] skip: usize, + /// Include memo text from OP_RETURN outputs + #[arg(long)] + with_memos: bool, }, /// List won spaces including ones /// still in auction with a winning bid @@ -720,6 +726,58 @@ fn parse_ptr_for_json(ptr: &spaces_ptr::FullPtrOut) -> serde_json::Value { ptr_json } +fn parse_space_for_json(space: &spaces_protocol::FullSpaceOut) -> serde_json::Value { + use spaces_ptr::vtlv; + + let mut space_json = serde_json::to_value(space).expect("space should be serializable"); + + // Check if covenant has data field (only Transfer covenant has data) + if let Some(obj) = space_json.as_object_mut() { + if let Some(covenant) = obj.get_mut("covenant") { + if let Some(covenant_obj) = covenant.as_object_mut() { + // Check if covenant type is "transfer" and has "data" field + if covenant_obj.get("type").and_then(|t| t.as_str()) == Some("transfer") { + if let Some(data) = covenant_obj.remove("data") { + // Skip if data is null + if !data.is_null() { + // Bytes serializes as hex string in JSON + if let Some(hex_str) = data.as_str() { + if let Ok(data_bytes) = hex::decode(hex_str) { + match vtlv::parse_vtlv(&data_bytes) { + Ok(parsed) => { + // Insert parsed with records structure + if let Ok(parsed_value) = serde_json::to_value(parsed) { + covenant_obj.insert("parsed".to_string(), parsed_value); + } else { + // If serialization fails, keep original data + covenant_obj.insert("data".to_string(), data); + } + } + Err(_) => { + // If parsing fails, keep the original data + covenant_obj.insert("data".to_string(), data); + } + } + } else { + covenant_obj.insert("data".to_string(), data); + } + } else { + // Not a string, keep as-is + covenant_obj.insert("data".to_string(), data); + } + } else { + // Data is null, keep it as null + covenant_obj.insert("data".to_string(), data); + } + } + } + } + } + } + + space_json +} + async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), ClientError> { match command { Commands::GetRollout { @@ -735,7 +793,15 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client Commands::GetSpace { space } => { let space = normalize_space(&space); let response = cli.client.get_space(&space).await?; - println!("{}", serde_json::to_string_pretty(&response)?); + match response { + Some(space_out) => { + let parsed_space = parse_space_for_json(&space_out); + println!("{}", serde_json::to_string_pretty(&parsed_space)?); + } + None => { + println!("null"); + } + } } Commands::GetSpaceOut { outpoint } => { let response = cli.client.get_spaceout(outpoint).await?; @@ -900,12 +966,24 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client Commands::SendCoins { amount, to, + memo, fee_rate, } => { + // Validate memo length if provided + if let Some(ref memo_text) = memo { + if memo_text.len() > 80 { + return Err(ClientError::Custom(format!( + "memo length ({}) exceeds maximum of 80 characters", + memo_text.len() + ))); + } + } + cli.send_request( Some(RpcWalletRequest::SendCoins(SendCoinsParams { amount: Amount::from_sat(amount), to, + memo: memo.clone(), })), None, fee_rate, @@ -970,12 +1048,12 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client let bidouts = cli.client.wallet_list_bidouts(&cli.wallet).await?; print_list_bidouts(bidouts, cli.format); } - Commands::ListTransactions { count, skip } => { + Commands::ListTransactions { count, skip, with_memos } => { let txs = cli .client - .wallet_list_transactions(&cli.wallet, count, skip) + .wallet_list_transactions(&cli.wallet, count, skip, with_memos) .await?; - print_list_transactions(txs, cli.format); + print_list_transactions(txs, cli.format, with_memos); } Commands::ListSpaces => { let tip = cli.client.get_server_info().await?; @@ -1297,6 +1375,15 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client } let delegation = delegation.unwrap(); + // Verify the PTR actually exists before trying to transfer it + let ptr_info = cli.client.get_ptr(delegation).await?; + if ptr_info.is_none() { + return Err(ClientError::Custom(format!( + "authorize: PTR '{}' for delegation of '{}' does not exist. The delegation may have been revoked or the PTR was never created.", + delegation, label + ))); + } + cli.send_request( Some(RpcWalletRequest::Transfer(TransferSpacesParams { spaces: vec![SpaceOrPtr::Ptr(delegation)], diff --git a/client/src/format.rs b/client/src/format.rs index 4ffdb24..8a538af 100644 --- a/client/src/format.rs +++ b/client/src/format.rs @@ -166,10 +166,28 @@ pub fn print_list_all_spaces( } } -pub fn print_list_transactions(txs: Vec, format: Format) { +pub fn print_list_transactions(txs: Vec, format: Format, with_memos: bool) { match format { Format::Text => { - println!("{}", ascii_table(txs)); + if with_memos { + // Print transactions with memos displayed separately + println!("{}", ascii_table(txs.iter().map(|tx| { + let mut display_tx = tx.clone(); + // Temporarily remove memo from table display + display_tx.memo = None; + display_tx + }).collect::>())); + + // Print memos separately + for tx in &txs { + if let Some(ref memo) = tx.memo { + println!("\nTransaction {}:", tx.txid); + println!(" Memo: {}", memo); + } + } + } else { + println!("{}", ascii_table(txs)); + } } Format::Json => { println!("{}", serde_json::to_string_pretty(&txs).unwrap()); diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 082eeb3..d68653f 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -440,6 +440,7 @@ pub trait Rpc { wallet: &str, count: usize, skip: usize, + with_memos: bool, ) -> Result, ErrorObjectOwned>; #[method(name = "walletforcespend")] @@ -584,6 +585,7 @@ pub struct SetPtrDataParams { pub struct SendCoinsParams { pub amount: Amount, pub to: String, + pub memo: Option, } #[derive(Clone, Serialize, Deserialize)] @@ -1395,10 +1397,11 @@ impl RpcServer for RpcServerImpl { wallet: &str, count: usize, skip: usize, + with_memos: bool, ) -> Result, ErrorObjectOwned> { self.wallet(&wallet) .await? - .send_list_transactions(count, skip) + .send_list_transactions(count, skip, with_memos) .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } diff --git a/client/src/wallets.rs b/client/src/wallets.rs index b4e4b2f..ae4210a 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -84,7 +84,7 @@ impl FromStr for ResolvableTarget { fn from_str(s: &str) -> Result { let s = s.trim(); if let Some(rest) = s.strip_prefix('@') { - return SLabel::from_str(rest) + return SLabel::from_str_unprefixed(rest) .map(ResolvableTarget::Space) .map_err(ResolvableTargetParseError::SpaceLabelParseError); } @@ -190,6 +190,8 @@ pub struct TxInfo { pub fee: Option, #[tabled(rename = "DETAILS", display_with = "display_events")] pub events: Vec, + #[tabled(skip)] + pub memo: Option, } fn display_block_height(block_height: &Option) -> String { @@ -249,6 +251,7 @@ pub enum WalletCommand { ListTransactions { count: usize, skip: usize, + with_memos: bool, resp: crate::rpc::Responder>>, }, ListSpaces { @@ -547,8 +550,8 @@ impl RpcWallet { WalletCommand::ListUnspent { resp } => { _ = resp.send(wallet.list_unspent_with_details(chain)); } - WalletCommand::ListTransactions { count, skip, resp } => { - let transactions = Self::list_transactions(wallet, count, skip); + WalletCommand::ListTransactions { count, skip, with_memos, resp } => { + let transactions = Self::list_transactions(wallet, count, skip, with_memos); _ = resp.send(transactions); } WalletCommand::ListSpaces { resp } => { @@ -958,7 +961,10 @@ impl RpcWallet { wallet: &mut SpacesWallet, count: usize, skip: usize, + with_memos: bool, ) -> anyhow::Result> { + use spaces_protocol::script::find_op_set_data; + let mut transactions: Vec<_> = wallet.transactions().collect(); transactions.sort(); @@ -976,6 +982,27 @@ impl RpcWallet { let txid = ctx.tx_node.txid.clone(); let (sent, received) = wallet.sent_and_received(&tx); let fee = wallet.calculate_fee(&tx).ok(); + + // Extract memo from OP_RETURN if requested + let memo = if with_memos { + find_op_set_data(&tx.output) + .and_then(|data_bytes| { + let data_slice = data_bytes.as_slice(); + // Try to decode as hex string first (since we encode memos as hex) + if let Ok(hex_str) = String::from_utf8(data_slice.to_vec()) { + if let Ok(decoded) = hex::decode(&hex_str) { + if let Ok(original_text) = String::from_utf8(decoded) { + return Some(original_text); + } + } + } + // Fallback: try to decode as UTF-8 directly + String::from_utf8(data_slice.to_vec()).ok() + }) + } else { + None + }; + TxInfo { block_height, txid, @@ -983,6 +1010,7 @@ impl RpcWallet { received, fee, events: vec![], + memo, } }) .collect(); @@ -1107,6 +1135,14 @@ impl RpcWallet { amount: params.amount, recipient: recipient.clone(), }); + + // Add memo as OP_RETURN output if provided + if let Some(memo_text) = ¶ms.memo { + // Convert memo text to hex-encoded bytes (hex string representation) + let hex_string = hex::encode(memo_text.as_bytes()); + let memo_bytes = hex_string.as_bytes().to_vec(); + builder = builder.add_data(memo_bytes); + } } RpcWalletRequest::Transfer(params) => { let recipient = if let Some(to) = params.to { @@ -1642,10 +1678,11 @@ impl RpcWallet { &self, count: usize, skip: usize, + with_memos: bool, ) -> anyhow::Result> { let (resp, resp_rx) = oneshot::channel(); self.sender - .send(WalletCommand::ListTransactions { count, skip, resp }) + .send(WalletCommand::ListTransactions { count, skip, with_memos, resp }) .await?; resp_rx.await? } From ab51cd157cce095e7f37e324d06dfbd72c40472f Mon Sep 17 00:00:00 2001 From: horologger Date: Sat, 13 Dec 2025 12:38:17 -0500 Subject: [PATCH 06/11] 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 07/11] 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 08/11] 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 09/11] 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 10/11] 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 11/11] 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()