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). 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..a699fc9 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", @@ -264,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 @@ -273,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, @@ -420,11 +434,17 @@ 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 #[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 +692,92 @@ 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 +} + +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 { @@ -687,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?; @@ -852,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, @@ -894,6 +1020,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 = @@ -919,18 +1048,23 @@ 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?; 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 +1234,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 +1271,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 @@ -1220,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 ceb52eb..8a538af 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,10 +124,70 @@ pub fn print_list_bidouts(bidouts: Vec, format: Format) { } } -pub fn print_list_transactions(txs: Vec, format: Format) { +pub fn print_list_all_spaces( + _current_block: u32, + spaces: Vec, + format: Format, +) { match format { Format::Text => { - println!("{}", ascii_table(txs)); + #[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, with_memos: bool) { + match format { + Format::Text => { + 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 42983f1..d68653f 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, @@ -427,6 +440,7 @@ pub trait Rpc { wallet: &str, count: usize, skip: usize, + with_memos: bool, ) -> Result, ErrorObjectOwned>; #[method(name = "walletforcespend")] @@ -546,6 +560,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)] @@ -569,6 +585,7 @@ pub struct SetPtrDataParams { pub struct SendCoinsParams { pub amount: Amount, pub to: String, + pub memo: Option, } #[derive(Clone, Serialize, Deserialize)] @@ -1075,6 +1092,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, @@ -1367,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::)) } @@ -1630,6 +1661,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 +2237,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 +2365,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..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 { @@ -1295,7 +1331,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)?; @@ -1638,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? } 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)); }