From 8dc4096597c2a4536df921b880619ab6039f3dc1 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Sat, 4 Oct 2025 21:06:22 +0200 Subject: [PATCH 1/6] Initial subs implementation --- Cargo.lock | 16 + Cargo.toml | 2 +- SUBSPACES.md | 68 +++ client/Cargo.toml | 1 + client/src/app.rs | 14 +- client/src/bin/space-cli.rs | 274 +++++++++++- client/src/checker.rs | 11 +- client/src/client.rs | 225 +++++++--- client/src/config.rs | 44 +- client/src/lib.rs | 2 +- client/src/rpc.rs | 339 ++++++++++++--- client/src/spaces.rs | 153 ++----- client/src/store/chain.rs | 529 +++++++++++++++++++++++ client/src/store/mod.rs | 39 ++ client/src/store/ptrs.rs | 360 +++++++++++++++ client/src/{store.rs => store/spaces.rs} | 67 +-- client/src/wallets.rs | 319 +++++++++----- client/tests/ptr_tests.rs | 357 +++++++++++++++ protocol/src/prepare.rs | 8 +- protocol/src/script.rs | 10 +- protocol/src/slabel.rs | 1 - ptr/Cargo.toml | 26 ++ ptr/src/constants.rs | 14 + ptr/src/lib.rs | 494 +++++++++++++++++++++ ptr/src/sptr.rs | 129 ++++++ wallet/Cargo.toml | 2 + wallet/src/builder.rs | 191 +++++++- wallet/src/lib.rs | 18 +- 28 files changed, 3238 insertions(+), 475 deletions(-) create mode 100644 SUBSPACES.md create mode 100644 client/src/store/chain.rs create mode 100644 client/src/store/mod.rs create mode 100644 client/src/store/ptrs.rs rename client/src/{store.rs => store/spaces.rs} (93%) create mode 100644 client/tests/ptr_tests.rs create mode 100644 ptr/Cargo.toml create mode 100644 ptr/src/constants.rs create mode 100644 ptr/src/lib.rs create mode 100644 ptr/src/sptr.rs diff --git a/Cargo.lock b/Cargo.lock index 44d0ef8..ab9952a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2356,6 +2356,7 @@ dependencies = [ "serde_json", "spacedb", "spaces_protocol", + "spaces_ptr", "spaces_testutil", "spaces_wallet", "tabled", @@ -2376,6 +2377,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "spaces_ptr" +version = "0.1.0" +dependencies = [ + "bech32", + "bincode", + "bitcoin", + "log", + "rand", + "serde", + "serde_json", + "spaces_protocol", +] + [[package]] name = "spaces_testutil" version = "0.0.1" @@ -2423,6 +2438,7 @@ dependencies = [ "serde", "serde_json", "spaces_protocol", + "spaces_ptr", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index fc35b08..518e255 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] resolver = "2" -members = [ "client", "protocol", "veritas", "testutil", "wallet"] +members = [ "client", "protocol", "veritas", "testutil", "wallet", "ptr"] diff --git a/SUBSPACES.md b/SUBSPACES.md new file mode 100644 index 0000000..9ea4871 --- /dev/null +++ b/SUBSPACES.md @@ -0,0 +1,68 @@ +# Guide to Subspaces + +## Operating a Space + +### 1. Initialize the Space + +Initialize your space for operation: + +```bash +$ space-cli operate @bitcoin +``` + +### 2. Issue Subspaces + +Use [subs](https://github.com/spacesprotocol/subs) to issue subspaces off-chain and create commitments. + +### 3. Submit Commitments + +Submit a commitment for your space with a Merkle root. Each commitment is cryptographically bound to all previous commitments. + +**Example:** To submit a commitment for `@bitcoin` with root hash `85d3a410db41b317b7c0310df64cefb6504482c0b5c7e8a36c992ed0dfdb38af`: + +```bash +$ space-cli commit @bitcoin 85d3a410db41b317b7c0310df64cefb6504482c0b5c7e8a36c992ed0dfdb38af +``` + +**Retrieve commitments** for a space: + +```bash +$ space-cli getcommitment @bitcoin +``` + +### Delegating Operational Control + +You can authorize another party to make commitments on your behalf: + +```bash +$ space-cli delegate @bitcoin --to +``` + +## Binding Handles On-Chain + +Handles like `alice@bitcoin` are bound to unique script pubkeys off-chain and are designed to remain off-chain by default. However, when on-chain interactivity is required, handles can be bound to UTXOs with minimal on-chain footprint. + +### Creating a Space Pointer + +Given a handle with its associated script pubkey: + +```json +{ + "handle": "alice@bitcoin", + "script_pubkey": "5120d3c3196cb3ed7fa79c882ed62f8e5942e546130d5ae5983da67dbb6c9bdd2e79" +} +``` + +You can create an on-chain identifier that only the controller of the script pubkey can use, without requiring additional metadata on-chain: + +```bash +$ space-cli createptr 5120d3c3196cb3ed7fa79c882ed62f8e5942e546130d5ae5983da67dbb6c9bdd2e79 +``` + +This command creates a UTXO with the same script pubkey and "mints" a space pointer (sptr) derived from it: + +``` +sptr13thcluavwywaktvv466wr6hykf7x5avg49hgdh7w8hh8chsqvwcskmtxpd +``` + +The space pointer serves as a permanent, transferable on-chain reference for the handle that can be sold and transferred like any other space UTXO. \ No newline at end of file diff --git a/client/Cargo.toml b/client/Cargo.toml index dc13ed5..411c558 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -18,6 +18,7 @@ path = "src/lib.rs" [dependencies] spaces_wallet = { path = "../wallet" } spaces_protocol = { path = "../protocol", version = "*", features = ["std"]} +spaces_ptr = { path = "../ptr" , features = ["std"]} spacedb = { git = "https://github.com/spacesprotocol/spacedb", version = "0.0.7" } tokio = { version = "1.37.0", features = ["signal"] } diff --git a/client/src/app.rs b/client/src/app.rs index 3f0697a..dc219a2 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -7,7 +7,7 @@ use crate::config::Args; use crate::rpc::{AsyncChainState, RpcServerImpl, WalletLoadRequest, WalletManager}; use crate::source::{BitcoinBlockSource, BitcoinRpc}; use crate::spaces::Spaced; -use crate::store::LiveSnapshot; +use crate::store::chain::{Chain}; use crate::wallets::RpcWallet; pub struct App { @@ -27,7 +27,7 @@ impl App { let wallet_service = RpcWallet::service( spaced.network, spaced.rpc.clone(), - spaced.chain.state.clone(), + spaced.chain.clone(), rx, self.shutdown.clone(), spaced.num_workers, @@ -52,11 +52,11 @@ impl App { wallets: Arc::new(Default::default()), }; + let chain_state = spaced.chain.clone(); let (async_chain_state, async_chain_state_handle) = create_async_store( spaced.rpc.clone(), spaced.anchors_path.clone(), - spaced.chain.state.clone(), - spaced.block_index.as_ref().map(|index| index.state.clone()), + chain_state, self.shutdown.subscribe(), ) .await; @@ -116,8 +116,7 @@ impl App { async fn create_async_store( rpc: BitcoinRpc, anchors: Option, - chain_state: LiveSnapshot, - block_index: Option, + state: Chain, shutdown: broadcast::Receiver<()>, ) -> (AsyncChainState, JoinHandle<()>) { let (tx, rx) = mpsc::channel(32); @@ -128,8 +127,7 @@ async fn create_async_store( &client, rpc, anchors, - chain_state, - block_index, + state, rx, shutdown, ) diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 3e89fe6..de00f41 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -5,7 +5,7 @@ use std::{ io::{Cursor, IsTerminal, Write}, path::PathBuf, }; - +use std::str::FromStr; use anyhow::anyhow; use base64::Engine; use clap::{Parser, Subcommand}; @@ -35,13 +35,16 @@ use spaces_client::{ serialize_base64, wallets::{AddressKind, WalletResponse}, }; +use spaces_client::rpc::{CommitParams, CreatePtrParams, TransferPtrParams}; +use spaces_client::store::Sha256; use spaces_protocol::bitcoin::{Amount, FeeRate, OutPoint, Txid}; -use spaces_wallet::{ - bitcoin::secp256k1::schnorr::Signature, - export::WalletExport, - nostr::{NostrEvent, NostrTag}, - Listing, -}; +use spaces_protocol::bitcoin::params::Params; +use spaces_protocol::slabel::SLabel; +use spaces_ptr::sptr::Sptr; +use spaces_wallet::{bitcoin, bitcoin::secp256k1::schnorr::Signature, export::WalletExport, nostr::{NostrEvent, NostrTag}, Listing}; +use spaces_wallet::address::SpaceAddress; +use spaces_wallet::bitcoin::hashes::sha256; +use spaces_wallet::bitcoin::ScriptBuf; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -150,6 +153,19 @@ enum Commands { /// The space name space: String, }, + /// Create a new ptr + #[command(name = "createptr")] + CreatePtr { + /// The script public key as hex string + spk: String, + fee_rate: Option, + }, + /// Get ptr info + #[command(name = "getptr")] + GetPtr { + /// The sha256 hash of the spk or the spk itself prefixed with hex: + spk: String, + }, /// Transfer ownership of a set of spaces to the given name or address #[command( name = "transfer", @@ -166,6 +182,22 @@ enum Commands { #[arg(long, short)] fee_rate: Option, }, + /// Transfer ownership of a set of ptrs to the given name or address + #[command( + name = "transferptr", + override_usage = "space-cli transferptr [PTRS]... --to " + )] + TransferPtr { + /// Ptrs to send + #[arg(display_order = 0)] + ptrs: Vec, + /// Recipient space name or address (must be a space address) + #[arg(long, display_order = 1)] + to: String, + /// Fee rate to use in sat/vB + #[arg(long, short)] + fee_rate: Option, + }, /// Renew ownership of a space #[command(name = "renew")] Renew { @@ -176,6 +208,59 @@ enum Commands { #[arg(long, short)] fee_rate: Option, }, + /// Initialize a space for operation of off-chain subspaces + #[command(name = "operate")] + Operate { + /// The space to apply new root + #[arg(display_order = 0)] + space: String, + /// Fee rate to use in sat/vB + #[arg(long, short)] + fee_rate: Option, + }, + /// Commit a new root + #[command(name = "commit")] + Commit { + /// The space to apply new root + #[arg(display_order = 0)] + space: String, + /// The new state root + #[arg(long, display_order = 1)] + root: sha256::Hash, + /// Fee rate to use in sat/vB + #[arg(long, short)] + fee_rate: Option, + }, + /// Delegate operation of a space to someone else + #[command(name = "delegate")] + Delegate { + /// Ptrs to send + #[arg(display_order = 0)] + space: String, + /// Recipient space name or address (must be a space address) + #[arg(long, display_order = 1)] + to: String, + /// Fee rate to use in sat/vB + #[arg(long, short)] + fee_rate: Option, + }, + /// Get the current space a sptr is responsible for + #[command(name = "getdelegator")] + GetDelegator { + sptr: Sptr, + }, + /// Get the current sptr responsible for a space + #[command(name = "getdelegation")] + GetDelegation { + space: SLabel, + }, + /// Get a commitment for a space + #[command(name = "getcommitment")] + GetCommitment { + space: SLabel, + // If no specific root, the most recent commitment will be fetched + root: Option, + }, /// Estimates the minimum bid needed for a rollout within the given target blocks #[command(name = "estimatebid")] EstimateBid { @@ -309,6 +394,12 @@ enum Commands { /// The OutPoint outpoint: OutPoint, }, + /// Get a ptrout + #[command(name = "getptrout")] + GetPtrOut { + /// The OutPoint + outpoint: OutPoint, + }, /// Get the estimated rollout batch for the specified interval #[command(name = "getrollout")] GetRollout { @@ -967,6 +1058,175 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client println!("{}", serde_json::to_string(&event).expect("result")); } + Commands::CreatePtr { spk, fee_rate } => { + let spk = ScriptBuf::from(hex::decode(spk) + .map_err(|_| ClientError::Custom("Invalid spk hex".to_string()))?); + + let addr = bitcoin::address::Address::from_script(spk.as_script(), + Params::new(cli.network.fallback_network())) + .map_err(|_| ClientError::Custom("Invalid spk".to_string()))?; + + let sptr = Sptr::from_spk::(spk); + println!("Creating sptr: {}", sptr); + cli.send_request( + Some(RpcWalletRequest::CreatePtr(CreatePtrParams { + address: addr.to_string(), + })), + None, + fee_rate, + false, + ) + .await? + } + Commands::TransferPtr { ptrs, to, fee_rate } => { + let mut parsed = Vec::with_capacity(ptrs.len()); + for ptr in ptrs { + parsed.push(Sptr::from_str(&ptr) + .map_err(|e| ClientError::Custom(format!("invalid sptr:{}: {}", ptr, e.to_string())))?); + } + + cli.send_request( + Some(RpcWalletRequest::TransferPtr(TransferPtrParams { + ptrs: parsed, + to, + })), + None, + fee_rate, + false, + ) + .await? + } + Commands::GetPtr { spk } => { + let sptr = Sptr::from_str(&spk) + .map_err(|e| ClientError::Custom(format!("input error: {}", e.to_string())))?; + + let ptr = cli + .client + .get_ptr(sptr) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + println!("{}", serde_json::to_string(&ptr).expect("result")); + } + + Commands::GetPtrOut { outpoint } => { + let ptrout = cli + .client + .get_ptrout(outpoint) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + println!("{}", serde_json::to_string(&ptrout).expect("result")); + } + Commands::Operate { space, fee_rate } => { + let space_info = match cli.client.get_space(&space).await? { + Some(space_info) => space_info, + None => return Err(ClientError::Custom("no such space".to_string())) + }; + let commitments_tip = cli.client.get_commitment( + space_info.spaceout.space.as_ref().expect("space").name.clone(), + None + ).await?; + if commitments_tip.is_some() { + return Err(ClientError::Custom("space is already operational".to_string())); + } + + let sptr_addr = cli.client.wallet_get_new_address(&cli.wallet, AddressKind::Space).await?; + let sptr = Sptr::from_spk::(SpaceAddress::from_str(&sptr_addr) + .expect("valid").script_pubkey()); + + println!("Assigning space to sptr {}", sptr); + cli.send_request( + Some(RpcWalletRequest::Transfer(TransferSpacesParams { + spaces: vec![space], + to: Some(sptr_addr.clone()), + })), + None, + fee_rate, + false, + ) + .await?; + + println!("Creating UTXO for sptr {}", sptr); + cli.send_request( + Some(RpcWalletRequest::CreatePtr(CreatePtrParams { + address: sptr_addr, + })), + None, + fee_rate, + false, + ) + .await?; + println!("Space should be operational was txs are confirmed"); + } + Commands::Commit { space, root, fee_rate } => { + let space_info = match cli.client.get_space(&space).await? { + Some(space_info) => space_info, + None => return Err(ClientError::Custom("no such space".to_string())) + }; + + let label = space_info.spaceout.space.as_ref().expect("space").name.clone(); + let delegation = cli.client.get_delegation(label.clone()).await?; + if delegation.is_none() { + return Err(ClientError::Custom("space is not operational - use operate @ first.".to_string())); + } + cli.send_request( + Some(RpcWalletRequest::Commit(CommitParams { + space: label.clone(), + root, + })), + None, + fee_rate, + false, + ) + .await?; + } + Commands::Delegate { space, to, fee_rate } => { + let space_info = match cli.client.get_space(&space).await? { + Some(space_info) => space_info, + None => return Err(ClientError::Custom("no such space".to_string())) + }; + + let label = space_info.spaceout.space.as_ref().expect("space").name.clone(); + let delegation = cli.client.get_delegation(label.clone()).await?; + if delegation.is_none() { + return Err(ClientError::Custom("space is not operational - use operate @ first.".to_string())); + } + let delegation = delegation.unwrap(); + + cli.send_request( + Some(RpcWalletRequest::TransferPtr(TransferPtrParams { + ptrs: vec![delegation], + to, + })), + None, + fee_rate, + false, + ) + .await?; + } + Commands::GetDelegator { sptr } => { + let delegator = cli + .client + .get_delegator(sptr) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + println!("{}", serde_json::to_string(&delegator).expect("result")); + } + Commands::GetDelegation { space } => { + let delegation = cli + .client + .get_delegation(space) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + println!("{}", serde_json::to_string(&delegation).expect("result")); + } + Commands::GetCommitment { space, root } => { + let c = cli + .client + .get_commitment(space, root) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + println!("{}", serde_json::to_string(& c).expect("result")); + } } Ok(()) diff --git a/client/src/checker.rs b/client/src/checker.rs index b5b028e..9009b40 100644 --- a/client/src/checker.rs +++ b/client/src/checker.rs @@ -4,21 +4,22 @@ use anyhow::anyhow; use spaces_protocol::{ bitcoin::{OutPoint, Transaction}, hasher::{KeyHasher, SpaceKey}, - prepare::{DataSource, TxContext}, + prepare::{SpacesSource, TxContext}, validate::{TxChangeSet, UpdateKind, Validator}, Covenant, RevokeReason, SpaceOut, }; -use crate::store::{LiveSnapshot, Sha256}; +use crate::store::chain::Chain; +use crate::store::Sha256; pub struct TxChecker<'a> { - pub original: &'a mut LiveSnapshot, + pub original: &'a mut Chain, pub spaces: BTreeMap>, pub spaceouts: BTreeMap>, } impl<'a> TxChecker<'a> { - pub fn new(snap: &'a mut LiveSnapshot) -> Self { + pub fn new(snap: &'a mut Chain) -> Self { Self { original: snap, spaces: Default::default(), @@ -143,7 +144,7 @@ impl<'a> TxChecker<'a> { } } -impl DataSource for TxChecker<'_> { +impl SpacesSource for TxChecker<'_> { fn get_space_outpoint( &mut self, space_hash: &SpaceKey, diff --git a/client/src/client.rs b/client/src/client.rs index b056343..c5a5dad 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -15,13 +15,16 @@ use spaces_protocol::{ validate::{TxChangeSet, UpdateKind, Validator}, Bytes, Covenant, FullSpaceOut, RevokeReason, SpaceOut, }; -use spaces_wallet::bitcoin::{Network, Transaction}; +use spaces_ptr::{CommitmentKey, RegistryKey}; +use spaces_ptr::sptr::Sptr; +use spaces_wallet::bitcoin::{Network, ScriptBuf, Transaction}; use crate::{ source::BitcoinRpcError, - store::{ChainState, ChainStore, LiveSnapshot, LiveStore, Sha256}, }; use crate::source::BlockQueueResult; +use crate::store::chain::{Chain}; +use crate::store::Sha256; pub trait BlockSource { fn get_block_hash(&self, height: u32) -> Result; @@ -38,10 +41,10 @@ pub trait BlockSource { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlockFilterRpc { - pub hash: BlockHash, + pub hash: BlockHash, pub height: u32, #[serde( - serialize_with = "serialize_hex", + serialize_with = "serialize_hex", deserialize_with = "deserialize_hex" )] pub content: Vec, @@ -76,6 +79,7 @@ pub struct BlockchainInfo { #[derive(Debug, Clone)] pub struct Client { validator: Validator, + ptr_validator: spaces_ptr::Validator, tx_data: bool, } @@ -87,6 +91,22 @@ pub struct BlockMeta { pub tx_meta: Vec, } +/// A block structure containing validated transaction metadata for ptrs +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] +pub struct PtrBlockMeta { + pub height: u32, + pub tx_meta: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] +pub struct PtrTxEntry { + #[serde(flatten)] + pub changeset: spaces_ptr::TxChangeSet, + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub tx: Option, +} + + #[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] pub struct TxEntry { #[serde(flatten)] @@ -123,44 +143,75 @@ impl Client { pub fn new(tx_data: bool) -> Self { Self { validator: Validator::new(), + ptr_validator: spaces_ptr::Validator::new(), tx_data, } } - pub fn apply_block( - &mut self, - chain: &mut LiveStore, - height: u32, - block_hash: BlockHash, - block: Block, - get_block_data: bool, - ) -> Result> { + fn verify_block_connected(chain: &mut Chain, height: u32, block_hash: BlockHash, block: &Block) -> anyhow::Result<()> { + // Spaces tip must connect to block { - let tip = chain.state.tip.read().expect("read tip"); + let tip = chain.tip(); if tip.hash != block.header.prev_blockhash || tip.height + 1 != height { return Err(SyncError { checkpoint: tip.clone(), connect_to: (height, block_hash), } - .into()); + .into()); + } + } + // Ptrs tip must connect to block + { + let tip = chain.ptrs_tip(); + if tip.hash != block.header.prev_blockhash || tip.height + 1 != height { + return Err(SyncError { + checkpoint: tip.clone(), + connect_to: (height, block_hash), + } + .into()); } } - let mut block_data = BlockMeta { - height, - tx_meta: vec![], - }; + Ok(()) + } + pub(crate) fn scan_block( + &mut self, + chain: &mut Chain, + height: u32, + block_hash: BlockHash, + block: &Block, + index_spaces: bool, + index_ptrs: bool, + ) -> anyhow::Result<(Option, Option)> { + Self::verify_block_connected(chain, height, block_hash, block)?; + + let mut spaces_meta = None; + if index_spaces { + spaces_meta = Some(BlockMeta { + height, + tx_meta: vec![], + }); + } + + let mut ptr_meta = None; + if index_ptrs { + ptr_meta = Some(PtrBlockMeta { + height, + tx_meta: vec![], + }); + } + + // Rollouts: if (height - 1) % ROLLOUT_BLOCK_INTERVAL == 0 { let batch = Self::get_rollout_batch(ROLLOUT_BATCH_SIZE, chain)?; let coinbase = block .coinbase() .expect("expected a coinbase tx to be present in the block") .clone(); - let validated = self.validator.rollout(height, &coinbase, batch); - if get_block_data { - block_data.tx_meta.push(TxEntry { + if let Some(idx) = spaces_meta.as_mut() { + idx.tx_meta.push(TxEntry { changeset: validated.clone(), tx: if self.tx_data { Some(TxData { @@ -174,18 +225,19 @@ impl Client { }, }); } - self.apply_tx(&mut chain.state, &coinbase, validated); + self.apply_space_tx(chain, &coinbase, validated); } - for (position, tx) in block.txdata.into_iter().enumerate() { - let prepared_tx = - { TxContext::from_tx::(&mut chain.state, &tx)? }; + for (position, tx) in block.txdata.iter().enumerate() { + let mut spaceouts = None; + let mut spaceouts_input_ctx = None; + if let Some(prepared) = TxContext::from_tx::(chain, tx)? { + spaceouts_input_ctx = Some(prepared.inputs.clone()); + let validated_tx = self.validator.process(height, &tx, prepared); + spaceouts = Some(validated_tx.creates.clone()); - if let Some(prepared_tx) = prepared_tx { - let validated_tx = self.validator.process(height, &tx, prepared_tx); - - if get_block_data { - block_data.tx_meta.push(TxEntry { + if let Some(idx) = spaces_meta.as_mut() { + idx.tx_meta.push(TxEntry { changeset: validated_tx.clone(), tx: if self.tx_data { Some(TxData { @@ -199,25 +251,95 @@ impl Client { }, }); } - self.apply_tx(&mut chain.state, &tx, validated_tx); + self.apply_space_tx(chain, &tx, validated_tx); + } + + let ptrs_ctx = + { spaces_ptr::TxContext::from_tx::(chain, tx, spaceouts.is_some() || spaceouts_input_ctx.is_some())? }; + + if let Some(ptrs_ctx) = ptrs_ctx { + let spent_spaceouts = spaceouts_input_ctx.unwrap_or_default().into_iter() + .map(|input| input.sstxo.previous_output).collect::>(); + let created_spaceouts = spaceouts.unwrap_or_default(); + let ptrs_validated = self.ptr_validator + .process::(height, &tx, ptrs_ctx, spent_spaceouts, created_spaceouts); + + if let Some(idx) = ptr_meta.as_mut() { + { + idx.tx_meta.push(PtrTxEntry { + changeset: ptrs_validated.clone(), + tx: if self.tx_data { + Some(TxData { + position: position as u32, + raw: Bytes::new( + spaces_protocol::bitcoin::consensus::encode::serialize(&tx), + ), + }) + } else { + None + }, + }); + } + self.apply_ptrs_tx(chain, tx, ptrs_validated); + } } } - let mut tip = chain.state.tip.write().expect("write tip"); - tip.height = height; - tip.hash = block_hash; - if get_block_data && !block_data.tx_meta.is_empty() { - return Ok(Some(block_data)); + chain.update_spaces_tip(height, block_hash); + chain.update_ptrs_tip(height, block_hash); + + Ok((spaces_meta, ptr_meta)) + } + + fn apply_ptrs_tx(&self, state: &mut Chain, tx: &Transaction, changeset: spaces_ptr::TxChangeSet) { + // Remove spends + for n in changeset.spends.into_iter() { + let previous = tx.input[n].previous_output; + state.remove_ptr_utxo(previous); + } + + // Remove revoked delegations + for revoked in changeset.revoked_delegations { + state.remove_delegation(revoked); + } + // Create new delegations + for delegation in changeset.new_delegations { + state.insert_delegation(delegation.sptr_key, delegation.space); + } + + // Insert new commitments + for (space, commitment) in changeset.commitments { + let commitment_key = CommitmentKey::new::(&space, commitment.state_root); + // Points space -> commitments tip + state.insert_registry(RegistryKey::from_slabel::(&space), commitment.state_root); + // commitment key = HASH(HASH(space) || state root) -> commitment + state.insert_commitment(commitment_key, commitment); + } + + // Create ptrs + for create in changeset.creates.into_iter() { + let outpoint = OutPoint { + txid: changeset.txid, + vout: create.n as u32, + }; + + // Ptr => Outpoint + if let Some(ptr) = create.sptr.as_ref() { + let ptr_key = Sptr::from_spk::(ScriptBuf::from(ptr.genesis_spk.clone())); + state.insert_ptr(ptr_key, outpoint.into()); + } + + // Outpoint => PtrOut + let outpoint_key = OutpointKey::from_outpoint::(outpoint); + state.insert_ptrout(outpoint_key, create); } - Ok(None) } - fn apply_tx(&self, state: &mut LiveSnapshot, tx: &Transaction, changeset: TxChangeSet) { + fn apply_space_tx(&self, state: &mut Chain, tx: &Transaction, changeset: TxChangeSet) { // Remove spends for spend in changeset.spends.into_iter() { let previous = tx.input[spend.n].previous_output; - let spend = OutpointKey::from_outpoint::(previous); - state.remove(spend); + state.remove_space_utxo(previous); } // Apply outputs @@ -258,7 +380,7 @@ impl Client { // Remove Space -> Outpoint let space_key = SpaceKey::from(base_hash); - state.remove(space_key); + state.remove_space(space_key); // Remove any bids from pre-auction pool match space.covenant { @@ -269,7 +391,7 @@ impl Client { } => { if claim_height.is_none() { let bid_key = BidKey::from_bid(total_burned, base_hash); - state.remove(bid_key); + state.remove_bid(bid_key) } } _ => {} @@ -280,9 +402,7 @@ impl Client { // 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 - let hash = - OutpointKey::from_outpoint::(update.output.outpoint()); - state.remove(hash); + state.remove_space_utxo(update.output.outpoint()); } } } @@ -302,7 +422,7 @@ impl Client { let outpoint_key = OutpointKey::from_outpoint::(update.output.outpoint()); - state.remove(bid_key); + state.remove_bid(bid_key); state.insert_spaceout(outpoint_key, update.output.spaceout); } UpdateKind::Bid => { @@ -350,14 +470,14 @@ impl Client { } } - fn get_rollout_batch(size: usize, chain: &mut LiveStore) -> Result> { - let (iter, snapshot) = chain.store.rollout_iter()?; + fn get_rollout_batch(size: usize, chain: &mut Chain) -> Result> { + let (iter, snapshot) = chain.rollout_iter()?; assert_eq!( snapshot.metadata(), - chain.state.inner()?.metadata(), + chain.spaces_tip_meatadata()?, "rollout snapshots don't match" ); - assert!(!chain.state.is_dirty(), "rollout must begin on clean state"); + assert!(!chain.is_dirty(), "rollout must begin on clean state"); let mut spaceouts = Vec::with_capacity(size); @@ -367,7 +487,7 @@ impl Client { hash.copy_from_slice(raw_hash.as_slice()); let space_hash = SpaceKey::from_raw(hash)?; - let full = chain.state.get_space_info(&space_hash)?; + let full = chain.get_space_info(&space_hash)?; if let Some(full) = full { match full.spaceout.space.as_ref().unwrap().covenant { @@ -398,7 +518,6 @@ fn unwrap_bid_value(spaceout: &SpaceOut) -> (Amount, Amount) { panic!("expected a bid covenant") } - fn serialize_hex(bytes: &Vec, s: S) -> std::result::Result where S: Serializer, @@ -412,4 +531,4 @@ where { let s = String::deserialize(d)?; hex::decode(s).map_err(D::Error::custom) -} \ No newline at end of file +} diff --git a/client/src/config.rs b/client/src/config.rs index 657cf18..6685158 100644 --- a/client/src/config.rs +++ b/client/src/config.rs @@ -21,8 +21,8 @@ use crate::{ auth::{auth_token_from_cookie, auth_token_from_creds}, source::{BitcoinRpc, BitcoinRpcAuth}, spaces::Spaced, - store::{LiveStore, Store}, }; +use crate::store::chain::Chain; const RPC_OPTIONS: &str = "RPC Server Options"; @@ -196,45 +196,18 @@ impl Args { let genesis = Spaced::genesis(args.chain); - let proto_db_path = data_dir.join("protocol.sdb"); - let initial_sync = !proto_db_path.exists(); - - let chain_store = Store::open(proto_db_path)?; - let chain = LiveStore { - state: chain_store.begin(&genesis)?, - store: chain_store, - }; + let chain = Chain::load( + args.chain.fallback_network(), + genesis, + &data_dir, + args.block_index || args.block_index_full, + args.block_index || args.block_index_full, // TODO: option to index ptrs + )?; let anchors_path = match args.skip_anchors { true => None, false => Some(data_dir.join("root_anchors.json")), }; - let block_index_enabled = args.block_index || args.block_index_full; - let block_index = if block_index_enabled { - let block_db_path = data_dir.join("block_index.sdb"); - if !initial_sync && !block_db_path.exists() { - return Err(anyhow::anyhow!( - "Block index must be enabled from the initial sync." - )); - } - let block_store = Store::open(block_db_path)?; - let index = LiveStore { - state: block_store.begin(&genesis).expect("begin block index"), - store: block_store, - }; - { - let tip_1 = index.state.tip.read().expect("index"); - let tip_2 = chain.state.tip.read().expect("tip"); - if tip_1.height != tip_2.height || tip_1.hash != tip_2.hash { - return Err(anyhow::anyhow!( - "Protocol and block index states don't match." - )); - } - } - Some(index) - } else { - None - }; Ok(Spaced { network: args.chain, @@ -243,7 +216,6 @@ impl Args { bind: rpc_bind_addresses, auth_token, chain, - block_index, block_index_full: args.block_index_full, num_workers: args.jobs as usize, anchors_path, diff --git a/client/src/lib.rs b/client/src/lib.rs index 13e491c..27a5a37 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -17,10 +17,10 @@ pub mod format; pub mod rpc; pub mod source; pub mod store; -pub mod spaces; pub mod wallets; mod cbf; pub mod app; +mod spaces; fn std_wait(mut predicate: F, wait: Duration) where diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 447a583..1dca23a 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -31,8 +31,8 @@ use spaces_protocol::{ OutPoint, }, constants::ChainAnchor, - hasher::{BaseHash, KeyHasher, OutpointKey, SpaceKey}, - prepare::DataSource, + hasher::{KeyHasher, OutpointKey, SpaceKey}, + prepare::SpacesSource, slabel::SLabel, validate::TxChangeSet, Bytes, Covenant, FullSpaceOut, SpaceOut, @@ -47,7 +47,10 @@ use tokio::{ sync::{broadcast, mpsc, oneshot, RwLock}, task::JoinSet, }; - +use spaces_protocol::hasher::Hash; +use spaces_ptr::{PtrSource, FullPtrOut, PtrOut, Commitment, RegistryKey, CommitmentKey, RegistrySptrKey}; +use spaces_ptr::sptr::Sptr; +use spaces_wallet::bitcoin::hashes::sha256; use crate::auth::BasicAuthLayer; use crate::wallets::WalletInfoWithProgress; use crate::{ @@ -57,13 +60,14 @@ use crate::{ config::ExtendedNetwork, deserialize_base64, serialize_base64, source::BitcoinRpc, - spaces::{COMMIT_BLOCK_INTERVAL, ROOT_ANCHORS_COUNT}, - store::{ChainState, LiveSnapshot, RolloutEntry, Sha256}, wallets::{ AddressKind, ListSpacesResponse, RpcWallet, TxInfo, TxResponse, WalletCommand, WalletResponse, }, }; +use crate::store::chain::{Chain, COMMIT_BLOCK_INTERVAL, ROOT_ANCHORS_COUNT}; +use crate::store::Sha256; +use crate::store::spaces::RolloutEntry; pub(crate) type Responder = oneshot::Sender; @@ -76,6 +80,7 @@ pub struct ServerInfo { pub progress: f32, } + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChainInfo { pub blocks: u32, @@ -126,6 +131,31 @@ pub enum ChainStateCommand { hash: SpaceKey, resp: Responder>>, }, + GetCommitment { + space: SLabel, + root: Option, + resp: Responder>>, + }, + GetDelegation { + space: SLabel, + resp: Responder>>, + }, + GetDelegator { + sptr: Sptr, + resp: Responder>>, + }, + GetPtr { + hash: Sptr, + resp: Responder>>, + }, + GetPtrOutpoint { + hash: Sptr, + resp: Responder>>, + }, + GetPtrOut { + outpoint: OutPoint, + resp: Responder>>, + }, GetTxMeta { txid: Txid, resp: Responder>>, @@ -170,6 +200,7 @@ pub struct AsyncChainState { sender: mpsc::Sender, } + #[rpc(server, client)] pub trait Rpc { #[method(name = "getserverinfo")] @@ -190,6 +221,30 @@ pub trait Rpc { #[method(name = "getspaceout")] async fn get_spaceout(&self, outpoint: OutPoint) -> Result, ErrorObjectOwned>; + #[method(name = "getptr")] + async fn get_ptr( + &self, + ptr: Sptr, + ) -> Result, ErrorObjectOwned>; + + #[method(name = "getptrowner")] + async fn get_ptr_owner( + &self, + ptr: Sptr, + ) -> Result, ErrorObjectOwned>; + + #[method(name = "getptrout")] + async fn get_ptrout(&self, outpoint: OutPoint) -> Result, ErrorObjectOwned>; + + #[method(name = "getcommitment")] + async fn get_commitment(&self, space: SLabel, root: Option) -> Result, ErrorObjectOwned>; + + #[method(name = "getdelegation")] + async fn get_delegation(&self, space: SLabel) -> Result, ErrorObjectOwned>; + + #[method(name = "getdelegator")] + async fn get_delegator(&self, sptr: Sptr) -> Result, ErrorObjectOwned>; + #[method(name = "checkpackage")] async fn check_package( &self, @@ -237,7 +292,7 @@ pub trait Rpc { #[method(name = "walletgetinfo")] async fn wallet_get_info(&self, name: &str) - -> Result; + -> Result; #[method(name = "walletexport")] async fn wallet_export(&self, name: &str) -> Result; @@ -367,6 +422,12 @@ pub enum RpcWalletRequest { Execute(ExecuteParams), #[serde(rename = "transfer")] Transfer(TransferSpacesParams), + #[serde(rename = "transferptr")] + TransferPtr(TransferPtrParams), + #[serde(rename = "createptr")] + CreatePtr(CreatePtrParams), + #[serde(rename = "commit")] + Commit(CommitParams), #[serde(rename = "send")] SendCoins(SendCoinsParams), } @@ -379,6 +440,23 @@ pub struct TransferSpacesParams { pub to: Option, } +#[derive(Clone, Serialize, Deserialize)] +pub struct TransferPtrParams { + pub ptrs: Vec, + pub to: String, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct CreatePtrParams { + pub address: String, +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct CommitParams { + pub space: SLabel, + pub root: sha256::Hash, +} + #[derive(Clone, Serialize, Deserialize)] pub struct SendCoinsParams { pub amount: Amount, @@ -815,6 +893,61 @@ impl RpcServer for RpcServerImpl { Ok(spaceout) } + async fn get_ptr(&self, sptr: Sptr) -> Result, ErrorObjectOwned> { + let info = self + .store + .get_ptr(sptr) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::))?; + Ok(info) + } + + async fn get_ptr_owner(&self, sptr: Sptr) -> Result, ErrorObjectOwned> { + let info = self + .store + .get_ptr_outpoint(sptr) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::))?; + Ok(info) + } + + async fn get_ptrout(&self, outpoint: OutPoint) -> Result, ErrorObjectOwned> { + let spaceout = self + .store + .get_ptrout(outpoint) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::))?; + Ok(spaceout) + } + + async fn get_commitment(&self, space: SLabel, root: Option) -> Result, ErrorObjectOwned> { + let c = self + .store + .get_commitment(space, root.map(|r| *r.as_ref())) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::))?; + Ok(c) + } + + async fn get_delegation(&self, space: SLabel) -> Result, ErrorObjectOwned> { + let delegation = self + .store + .get_delegation(space) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::))?; + Ok(delegation) + } + + async fn get_delegator(&self, sptr: Sptr) -> Result, ErrorObjectOwned> { + let delegator = self + .store + .get_delegator(sptr) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::))?; + Ok(delegator) + } + + async fn check_package( &self, txs: Vec, @@ -1126,11 +1259,10 @@ impl AsyncChainState { } async fn get_indexed_tx( - index: &mut Option, + state: &mut Chain, txid: &Txid, client: &reqwest::Client, rpc: &BitcoinRpc, - chain_state: &mut LiveSnapshot, ) -> Result, anyhow::Error> { let info: serde_json::Value = rpc .send_json(client, &rpc.get_raw_transaction(&txid, true)) @@ -1142,13 +1274,12 @@ impl AsyncChainState { || anyhow!("Could not retrieve block hash for tx (is it in the mempool?)"), )?)?; let block = Self::get_indexed_block( - index, + state, HeightOrHash::Hash(block_hash), client, rpc, - chain_state, ) - .await?; + .await?; Ok(block .block_meta @@ -1158,15 +1289,14 @@ impl AsyncChainState { } async fn get_indexed_block( - index: &mut Option, + state: &mut Chain, height_or_hash: HeightOrHash, client: &reqwest::Client, rpc: &BitcoinRpc, - chain_state: &mut LiveSnapshot, ) -> Result { - let index = index - .as_mut() - .ok_or_else(|| anyhow!("block index must be enabled"))?; + // let index = state + // .as_mut() + // .ok_or_else(|| anyhow!("block index must be enabled"))?; let hash = match height_or_hash { HeightOrHash::Hash(hash) => hash, HeightOrHash::Height(height) => rpc @@ -1175,11 +1305,8 @@ impl AsyncChainState { .map_err(|e| anyhow!("Could not retrieve block hash ({})", e))?, }; - if let Some(block_meta) = index - .get(BaseHash::from_slice(hash.as_ref())) - .context("Could not fetch block from index")? - { - return Ok(BlockMetaWithHash { hash, block_meta }); + if let Some(block_meta) = state.get_spaces_block(hash)? { + return Ok(block_meta); } let info: serde_json::Value = rpc @@ -1193,7 +1320,7 @@ impl AsyncChainState { .and_then(|h| u32::try_from(h).ok()) .ok_or_else(|| anyhow!("Could not retrieve block height"))?; - let tip = chain_state.tip.read().expect("read meta").clone(); + let tip = state.tip(); if height > tip.height { return Err(anyhow!( "Spaces is syncing at height {}, requested block height {}", @@ -1214,8 +1341,7 @@ impl AsyncChainState { client: &reqwest::Client, rpc: &BitcoinRpc, anchors_path: &Option, - chain_state: &mut LiveSnapshot, - block_index: &mut Option, + state: &mut Chain, cmd: ChainStateCommand, ) { match cmd { @@ -1230,60 +1356,89 @@ impl AsyncChainState { txs.push(tx.unwrap()); } - let tip = chain_state.tip.read().expect("read meta").clone(); - let mut emulator = TxChecker::new(chain_state); + let tip = state.tip(); + let mut emulator = TxChecker::new(state); let result = emulator.apply_package(tip.height + 1, txs); let _ = resp.send(result); } ChainStateCommand::GetServerInfo { resp } => { - let tip = chain_state.tip.read().expect("read meta").clone(); + let tip = state.tip(); _ = resp.send(get_server_info(client, rpc, tip).await) } ChainStateCommand::GetSpace { hash, resp } => { - let result = chain_state.get_space_info(&hash); + let result = state.get_space_info(&hash); let _ = resp.send(result); } ChainStateCommand::GetSpaceout { outpoint, resp } => { - let result = chain_state + let result = state .get_spaceout(&outpoint) .context("could not fetch spaceout"); let _ = resp.send(result); } ChainStateCommand::GetSpaceOutpoint { hash, resp } => { - let result = chain_state + let result = state .get_space_outpoint(&hash) .context("could not fetch spaceout"); let _ = resp.send(result); } + ChainStateCommand::GetPtr { hash, resp } => { + let result = state.get_ptr_info(&hash); + let _ = resp.send(result); + } + ChainStateCommand::GetPtrOutpoint { hash, resp } => { + let result = state + .get_ptr_outpoint(&hash) + .context("could not fetch ptrout"); + let _ = resp.send(result); + } + ChainStateCommand::GetCommitment { space, root, resp } => { + let result = get_commitment(state, space, root); + let _ = resp.send(result); + } + ChainStateCommand::GetDelegation { space, resp } => { + let result = get_delegation(state, space); + let _ = resp.send(result); + } + ChainStateCommand::GetDelegator { sptr, resp } => { + let result = state + .get_delegator(&RegistrySptrKey::from_sptr::(sptr)).map_err(|e| anyhow!("could not get delegator: {}", e)); + let _ = resp.send(result); + } + ChainStateCommand::GetPtrOut { outpoint, resp } => { + let result = state + .get_ptrout(&outpoint) + .context("could not fetch ptrouts"); + let _ = resp.send(result); + } ChainStateCommand::GetBlockMeta { height_or_hash, resp, } => { let res = - Self::get_indexed_block(block_index, height_or_hash, client, rpc, chain_state) + Self::get_indexed_block(state, height_or_hash, client, rpc) .await; let _ = resp.send(res); } ChainStateCommand::GetTxMeta { txid, resp } => { - let res = Self::get_indexed_tx(block_index, &txid, client, rpc, chain_state).await; + let res = Self::get_indexed_tx(state, &txid, client, rpc).await; let _ = resp.send(res); } ChainStateCommand::EstimateBid { target, resp } => { - let estimate = chain_state.estimate_bid(target); + let estimate = state.estimate_bid(target); _ = resp.send(estimate); } ChainStateCommand::GetRollout { target, resp } => { - let rollouts = chain_state.get_rollout(target); + let rollouts = state.get_rollout(target); _ = resp.send(rollouts); } ChainStateCommand::VerifyListing { listing, resp } => { _ = resp.send( - SpacesWallet::verify_listing::(chain_state, &listing).map(|_| ()), + SpacesWallet::verify_listing::(state, &listing).map(|_| ()), ); } ChainStateCommand::VerifyEvent { space, event, resp } => { _ = resp.send(SpacesWallet::verify_event::( - chain_state, + state, &space, event, )); @@ -1294,7 +1449,7 @@ impl AsyncChainState { resp, } => { _ = resp.send(Self::handle_prove_spaceout( - chain_state, + state, outpoint, prefer_recent, )); @@ -1304,30 +1459,30 @@ impl AsyncChainState { resp, } => { _ = resp.send(Self::handle_prove_space_outpoint( - chain_state, + state, &space_or_hash, )); } ChainStateCommand::GetRootAnchors { resp } => { - _ = resp.send(Self::handle_get_anchor(anchors_path, chain_state)); + _ = resp.send(Self::handle_get_anchor(anchors_path, state)); } } } fn handle_get_anchor( anchors_path: &Option, - state: &mut LiveSnapshot, + state: &mut Chain, ) -> anyhow::Result> { if let Some(anchors_path) = anchors_path { let anchors: Vec = serde_json::from_reader( File::open(anchors_path) .or_else(|e| Err(anyhow!("Could not open anchors file: {}", e)))?, ) - .or_else(|e| Err(anyhow!("Could not read anchors file: {}", e)))?; + .or_else(|e| Err(anyhow!("Could not read anchors file: {}", e)))?; return Ok(anchors); } - let snapshot = state.inner()?; + let snapshot = state.spaces_inner()?; let root = snapshot.compute_root()?; let meta: ChainAnchor = snapshot.metadata().try_into()?; Ok(vec![RootAnchor { @@ -1340,11 +1495,11 @@ impl AsyncChainState { } fn handle_prove_space_outpoint( - state: &mut LiveSnapshot, + state: &mut Chain, space_or_hash: &str, ) -> anyhow::Result { let key = get_space_key(space_or_hash)?; - let snapshot = state.inner()?; + let snapshot = state.spaces_inner()?; // warm up hash cache let root = snapshot.compute_root()?; @@ -1391,7 +1546,7 @@ impl AsyncChainState { } fn handle_prove_spaceout( - state: &mut LiveSnapshot, + state: &mut Chain, outpoint: OutPoint, prefer_recent: bool, ) -> anyhow::Result { @@ -1410,16 +1565,16 @@ impl AsyncChainState { None => return Ok(ProofResult { proof: vec![], root: Bytes::new(vec![]) }), Some(space) => match space.covenant { Covenant::Transfer { expire_height, .. } => { - let tip = state.tip.read().expect("read lock").height; + let tip = state.tip(); let last_update = expire_height.saturating_sub(spaces_protocol::constants::RENEWAL_INTERVAL); - Self::compute_target_snapshot(last_update, tip) + Self::compute_target_snapshot(last_update, tip.height) } _ => return Err(anyhow!("Cannot find older proofs for a non-registered space (try with oldest: false)")), } }; - state.prove_with_snapshot(&[key.into()], target_snapshot)? + state.prove_spaces_with_snapshot(&[key.into()], target_snapshot)? } else { - let snapshot = state.inner()?; + let snapshot = state.spaces_inner()?; snapshot.prove(&[key.into()], ProofType::Standard)? }; @@ -1439,8 +1594,7 @@ impl AsyncChainState { client: &reqwest::Client, rpc: BitcoinRpc, anchors_path: Option, - mut chain_state: LiveSnapshot, - mut block_index: Option, + mut state: Chain, mut rx: mpsc::Receiver, mut shutdown: broadcast::Receiver<()>, ) { @@ -1450,7 +1604,7 @@ impl AsyncChainState { break; } Some(cmd) = rx.recv() => { - Self::handle_command(client, &rpc, &anchors_path, &mut chain_state, &mut block_index, cmd).await; + Self::handle_command(client, &rpc, &anchors_path, &mut state, cmd).await; } } } @@ -1537,6 +1691,14 @@ impl AsyncChainState { resp_rx.await? } + pub async fn get_ptr(&self, hash: Sptr) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetPtr { hash, resp }) + .await?; + resp_rx.await? + } + pub async fn get_space_outpoint(&self, hash: SpaceKey) -> anyhow::Result> { let (resp, resp_rx) = oneshot::channel(); self.sender @@ -1545,6 +1707,14 @@ impl AsyncChainState { resp_rx.await? } + pub async fn get_ptr_outpoint(&self, hash: Sptr) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetPtrOutpoint { hash, resp }) + .await?; + resp_rx.await? + } + pub async fn check_package( &self, txs: Vec, @@ -1572,6 +1742,38 @@ impl AsyncChainState { resp_rx.await? } + pub async fn get_ptrout(&self, outpoint: OutPoint) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetPtrOut { outpoint, resp }) + .await?; + resp_rx.await? + } + + pub async fn get_commitment(&self, space: SLabel, root: Option) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetCommitment { space, root, resp }) + .await?; + resp_rx.await? + } + + pub async fn get_delegation(&self, space: SLabel) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetDelegation { space, resp }) + .await?; + resp_rx.await? + } + + pub async fn get_delegator(&self, sptr: Sptr) -> anyhow::Result> { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::GetDelegator { sptr, resp }) + .await?; + resp_rx.await? + } + pub async fn get_block_meta( &self, height_or_hash: HeightOrHash, @@ -1653,3 +1855,36 @@ async fn get_server_info( progress: calc_progress(start_block, tip.height, info.headers), }) } + + +fn get_delegation(state: &mut Chain, space: SLabel) -> anyhow::Result> { + let info = match state.get_space_info(&SpaceKey::from(Sha256::hash(space.as_ref())))? { + None => return Ok(None), + Some(info) => info + }; + let sptr = Sptr::from_spk::(info.spaceout.script_pubkey); + let delegate = state.get_delegator(&RegistrySptrKey::from_sptr::(sptr))?; + Ok(delegate.map(|_| sptr)) +} + +fn get_commitment(state: &mut Chain, space: SLabel, root: Option) -> anyhow::Result> { + let root = match root { + None => { + let rk = RegistryKey::from_slabel::(&space); + let k = state.get_commitments_tip(&rk) + .map_err(|e| anyhow!("could not fetch state root: {}", e))?; + if let Some(k) = k { + k + } else { + return Ok(None); + } + } + Some(r) => r, + }; + + let ck = CommitmentKey::new::(&space, root); + state.get_commitment(&ck) + .map_err(|e| + anyhow!("could not fetch commitment with root: {}: {}", hex::encode(root), e) + ) +} \ No newline at end of file diff --git a/client/src/spaces.rs b/client/src/spaces.rs index ed0cfee..e7d1743 100644 --- a/client/src/spaces.rs +++ b/client/src/spaces.rs @@ -1,43 +1,25 @@ use std::{net::SocketAddr, path::PathBuf, time::Duration}; -use anyhow::{anyhow, Context}; use log::{info, warn}; use spaces_protocol::{ - bitcoin::{Block, BlockHash}, + bitcoin::{Block}, constants::ChainAnchor, - hasher::BaseHash, }; use tokio::sync::broadcast; -pub const ROOT_ANCHORS_COUNT: u32 = 120; - use crate::{ - client::{BlockMeta, BlockSource, Client}, + client::{BlockSource, Client}, config::ExtendedNetwork, source::{ BitcoinBlockSource, BitcoinRpc, BitcoinRpcError, BlockEvent, BlockFetchError, BlockFetcher, }, std_wait, - store::LiveStore, }; - -// https://internals.rust-lang.org/t/nicer-static-assertions/15986 -macro_rules! const_assert { - ($($tt:tt)*) => { - const _: () = assert!($($tt)*); - } -} - -pub const COMMIT_BLOCK_INTERVAL: u32 = 36; -const_assert!( - spaces_protocol::constants::ROLLOUT_BLOCK_INTERVAL % COMMIT_BLOCK_INTERVAL == 0, - "commit and rollout intervals must be aligned" -); +use crate::store::chain::{Chain}; pub struct Spaced { pub network: ExtendedNetwork, - pub chain: LiveStore, - pub block_index: Option, + pub chain: Chain, pub block_index_full: bool, pub rpc: BitcoinRpc, pub data_dir: PathBuf, @@ -50,69 +32,11 @@ pub struct Spaced { } impl Spaced { - // Restores state to a valid checkpoint pub fn restore(&self, source: &BitcoinBlockSource) -> anyhow::Result<()> { - let chain_iter = self.chain.store.iter(); - for (snapshot_index, snapshot) in chain_iter.enumerate() { - let chain_snapshot = snapshot?; - let chain_checkpoint: ChainAnchor = chain_snapshot.metadata().try_into()?; - let required_hash = source.get_block_hash(chain_checkpoint.height)?; - - if required_hash != chain_checkpoint.hash { - info!( - "Could not restore to block={} height={}", - chain_checkpoint.hash, chain_checkpoint.height - ); - continue; - } - - info!( - "Restoring block={} height={}", - chain_checkpoint.hash, chain_checkpoint.height - ); - - if let Some(block_index) = self.block_index.as_ref() { - let index_snapshot = block_index.store.iter().skip(snapshot_index).next(); - if index_snapshot.is_none() { - return Err(anyhow!( - "Could not restore block index due to missing snapshot" - )); - } - let index_snapshot = index_snapshot.unwrap()?; - let index_checkpoint: ChainAnchor = index_snapshot.metadata().try_into()?; - if index_checkpoint != chain_checkpoint { - return Err(anyhow!( - "block index checkpoint does not match the chain's checkpoint" - )); - } - index_snapshot - .rollback() - .context("could not rollback block index snapshot")?; - } - - chain_snapshot - .rollback() - .context("could not rollback chain snapshot")?; - - self.chain.state.restore(chain_checkpoint.clone()); - - if let Some(block_index) = self.block_index.as_ref() { - block_index.state.restore(chain_checkpoint) - } - return Ok(()); - } - - Err(anyhow!("Unable to restore to a valid state")) - } - - pub fn save_block( - store: LiveStore, - block_hash: BlockHash, - block: BlockMeta, - ) -> anyhow::Result<()> { - store - .state - .insert(BaseHash::from_slice(block_hash.as_ref()), block); + self.chain.restore(|h| { + let h = source.get_block_hash(h)?; + Ok(h) + })?; Ok(()) } @@ -120,25 +44,14 @@ impl Spaced { if !self.synced { return Ok(()); } - info!("Updating root anchors ..."); + let anchors_path = match self.anchors_path.as_ref() { None => return Ok(()), Some(path) => path, }; - let result = self - .chain - .store - .update_anchors(anchors_path, ROOT_ANCHORS_COUNT) - .or_else(|e| Err(anyhow!("Could not update trust anchors: {}", e)))?; - - if let Some(result) = result.first() { - info!( - "Latest root anchor {} (height: {})", - hex::encode(result.root), - result.block.height - ) - } + info!("Updating root anchors ..."); + self.chain.update_anchors(anchors_path)?; Ok(()) } @@ -148,33 +61,27 @@ impl Spaced { id: ChainAnchor, block: Block, ) -> anyhow::Result<()> { - let index_blocks = self.block_index.is_some(); - let block_result = - node.apply_block(&mut self.chain, id.height, id.hash, block, index_blocks)?; + let sp_idx = self.chain.has_spaces_index(); + let pt_idx = self.chain.has_ptrs_index(); - if let Some(index) = self.block_index.as_mut() { - if let Some(block) = block_result { - Self::save_block(index.clone(), id.hash, block)?; - } - } + let (block_result,ptr_block_result) = node + .scan_block(&mut self.chain, id.height, id.hash, &block, sp_idx, pt_idx)?; - if id.height % COMMIT_BLOCK_INTERVAL == 0 { - let block_index_writer = self.block_index.clone(); - - let tx = self.chain.store.write().expect("write handle"); - let state_meta = ChainAnchor { - height: id.height, - hash: id.hash, - }; + if let Some(result) = block_result { + self.chain.apply_block_to_spaces_index(id.hash, result)?; + } + if let Some(result) = ptr_block_result { + self.chain.apply_block_to_ptrs_index(id.hash, result)?; + } - self.chain.state.commit(state_meta.clone(), tx)?; - if let Some(index) = block_index_writer { - let tx = index.store.write().expect("write handle"); - index.state.commit(state_meta, tx)?; - } + let new_tip = ChainAnchor { + height: id.height, + hash: id.hash, + }; + if self.chain.maybe_commit(new_tip)? { + // TODO: ptr anchors self.update_anchors()?; } - Ok(()) } @@ -183,7 +90,7 @@ impl Spaced { source: BitcoinBlockSource, shutdown: broadcast::Sender<()>, ) -> anyhow::Result<()> { - let start_block: ChainAnchor = { self.chain.state.tip.read().expect("read").clone() }; + let start_block = self.chain.tip(); let mut node = Client::new(self.block_index_full); info!( @@ -229,7 +136,7 @@ impl Spaced { std_wait(|| wait_recv.try_recv().is_ok(), Duration::from_secs(1)); } // Even if we couldn't restore just attempt to re-sync - let new_tip = self.chain.state.tip.read().expect("read").clone(); + let new_tip = self.chain.tip(); fetcher.restart(new_tip, &receiver); } BlockEvent::Error(e) => { @@ -237,7 +144,7 @@ impl Spaced { let mut wait_recv = shutdown.subscribe(); std_wait(|| wait_recv.try_recv().is_ok(), Duration::from_secs(1)); // Even if we couldn't restore just attempt to re-sync - let new_tip = self.chain.state.tip.read().expect("read").clone(); + let new_tip = self.chain.tip(); fetcher.restart(new_tip, &receiver); } }, diff --git a/client/src/store/chain.rs b/client/src/store/chain.rs new file mode 100644 index 0000000..1b278d3 --- /dev/null +++ b/client/src/store/chain.rs @@ -0,0 +1,529 @@ +use std::path::Path; +use anyhow::{anyhow, Context}; +use log::info; +use spacedb::{Hash, Sha256Hasher}; +use spacedb::subtree::SubTree; +use spaces_protocol::bitcoin::{BlockHash, OutPoint}; +use spaces_protocol::bitcoin::hashes::Hash as HashUtil; +use spaces_protocol::constants::ChainAnchor; +use spaces_protocol::hasher::{BaseHash, BidKey, OutpointKey, SpaceKey}; +use spaces_protocol::prepare::SpacesSource; +use spaces_protocol::{FullSpaceOut, SpaceOut}; +use spaces_protocol::slabel::SLabel; +use spaces_ptr::{Commitment, CommitmentKey, FullPtrOut, PtrOut, PtrSource, RegistryKey, RegistrySptrKey}; +use spaces_ptr::sptr::Sptr; +use spaces_wallet::bitcoin::Network; +use crate::client::{BlockMeta, PtrBlockMeta}; +use crate::rpc::BlockMetaWithHash; +use crate::store::{EncodableOutpoint, ReadTx, Sha256}; +use crate::store::ptrs::{PtrChainState, PtrLiveStore, PtrStore}; +use crate::store::spaces::{RolloutEntry, RolloutIterator, SpLiveStore, SpStore, SpStoreUtils, SpacesState}; + +pub const ROOT_ANCHORS_COUNT: u32 = 120; +pub const COMMIT_BLOCK_INTERVAL: u32 = 36; + +// https://internals.rust-lang.org/t/nicer-static-assertions/15986 +macro_rules! const_assert { + ($($tt:tt)*) => { + const _: () = assert!($($tt)*); + } +} + +const_assert!( + spaces_protocol::constants::ROLLOUT_BLOCK_INTERVAL % COMMIT_BLOCK_INTERVAL == 0, + "commit and rollout intervals must be aligned" +); + + +#[derive(Clone)] +pub struct Chain { + db: LiveStore, + idx: LiveIndex, +} + +#[derive(Clone)] +pub struct LiveStore { + sp: SpLiveStore, + pt: PtrLiveStore, +} + +#[derive(Clone)] +pub struct LiveIndex { + sp: Option, + pt: Option, +} + +impl SpacesSource for Chain { + fn get_space_outpoint(&mut self, space_hash: &SpaceKey) -> spaces_protocol::errors::Result> { + self.db.sp.state.get_space_outpoint(space_hash) + } + + fn get_spaceout(&mut self, outpoint: &OutPoint) -> spaces_protocol::errors::Result> { + self.db.sp.state.get_spaceout(outpoint) + } +} + +impl PtrSource for Chain { + fn get_ptr_outpoint(&mut self, space_hash: &Sptr) -> spaces_protocol::errors::Result> { + self.db.pt.state.get_ptr_outpoint(space_hash) + } + + fn get_commitment(&mut self, key: &CommitmentKey) -> spaces_protocol::errors::Result> { + self.db.pt.state.get_commitment(key) + } + + fn get_delegator(&mut self, sptr: &RegistrySptrKey) -> spaces_protocol::errors::Result> { + self.db.pt.state.get_delegator(sptr) + } + + fn get_commitments_tip(&mut self, key: &RegistryKey) -> spaces_protocol::errors::Result> { + self.db.pt.state.get_commitments_tip(key) + } + + fn get_ptrout(&mut self, outpoint: &OutPoint) -> spaces_protocol::errors::Result> { + self.db.pt.state.get_ptrout(outpoint) + } +} + +impl Chain { + pub fn get_space_info(&mut self, space_hash: &SpaceKey) -> anyhow::Result> { + self.db.sp.state.get_space_info(space_hash) + } + + pub fn get_ptr_info(&mut self, key: &Sptr) -> anyhow::Result> { + self.db.pt.state.get_ptr_info(key) + } + + pub fn load(network: Network, genesis: ChainAnchor, dir: &Path, index_spaces: bool, index_ptrs: bool) -> anyhow::Result { + let ptrs_genesis = spaces_ptr::constants::ptrs_start_height(&network); + let proto_db_path = dir.join("protocol.sdb"); + let ptrs_db_path = dir.join("ptrs.sdb"); + let initial_sp_sync = !proto_db_path.exists(); + let initial_pt_sync = !ptrs_db_path.exists(); + + let sp_store = SpStore::open(proto_db_path)?; + let sp = SpLiveStore { + state: sp_store.begin(&genesis)?, + store: sp_store, + }; + + let pt_store = PtrStore::open(ptrs_db_path)?; + let pt = PtrLiveStore { + state: pt_store.begin(&genesis)?, + store: pt_store, + }; + + + let mut sp_idx = None; + + if index_spaces { + let current_tip = sp.state.tip.read().expect("tip"); + sp_idx = Some(load_sp_index(dir, genesis, *current_tip, initial_sp_sync)?) + } + + let mut pt_idx = None; + if index_ptrs { + let current_tip = pt.state.tip.read().expect("tip"); + pt_idx = Some(load_pt_index(dir, genesis, *current_tip, initial_pt_sync)?) + } + + let chain = Chain { + db: LiveStore { sp, pt }, + idx: LiveIndex { sp: sp_idx, pt: pt_idx }, + }; + + // If spaces synced past the ptrs point, reset the tip + if initial_pt_sync { + let sp_tip = chain.db.sp.state.tip.read().expect("tip").clone(); + if sp_tip.height > ptrs_genesis { + info!("spaces tip = {} > ptrs genesis = {} - rescanning to index ptrs", + sp_tip.height, ptrs_genesis + ); + assert_eq!( + ptrs_genesis % COMMIT_BLOCK_INTERVAL, 0, + "ptrs genesis must align with commit interval" + ); + chain.restore_spaces(|height| { + if height != ptrs_genesis { + // return a dummy hash until we have a checkpoint matching ptrs genesis + return Ok(BlockHash::from_slice(&[0u8; 32]).expect("hash")); + } + + Ok(sp_tip.hash) + })?; + } + } + + Ok(chain) + } + + pub fn tip(&self) -> ChainAnchor { + self.db.sp.state.tip.read().expect("read").clone() + } + + pub fn apply_block_to_spaces_index( + &self, + block_hash: BlockHash, + block: BlockMeta, + ) -> anyhow::Result<()> { + if let Some(idx) = &self.idx.sp { + idx.state.insert(BaseHash::from_slice(block_hash.as_ref()), block); + } + Ok(()) + } + + pub fn apply_block_to_ptrs_index( + &self, + block_hash: BlockHash, + block: PtrBlockMeta, + ) -> anyhow::Result<()> { + if let Some(idx) = &self.idx.pt { + idx.state.insert(BaseHash::from_slice(block_hash.as_ref()), block); + } + Ok(()) + } + + pub fn maybe_commit(&self, checkpoint: ChainAnchor) -> anyhow::Result { + if checkpoint.height % COMMIT_BLOCK_INTERVAL != 0 { + return Ok(false); + } + + let spaces_batch = self.db.sp.store.write().expect("write handle"); + let ptrs_batch = self.db.pt.store.write().expect("write handle"); + + self.db.sp.state.commit(checkpoint.clone(), spaces_batch)?; + self.db.pt.state.commit(checkpoint.clone(), ptrs_batch)?; + + let sp_index_writer = self.idx.sp.clone(); + if let Some(index) = sp_index_writer { + let tx = index.store.write().expect("write handle"); + index.state.commit(checkpoint, tx)?; + } + + let pt_index_writer = self.idx.pt.clone(); + if let Some(index) = pt_index_writer { + let tx = index.store.write().expect("write handle"); + index.state.commit(checkpoint, tx)?; + } + Ok(true) + } + + pub fn spaces_mut(&mut self) -> &mut SpLiveStore { + &mut self.db.sp + } + + pub fn ptrs_mut(&mut self) -> &mut PtrLiveStore { + &mut self.db.pt + } + + pub fn has_spaces_index(&self) -> bool { + self.idx.sp.is_some() + } + + pub fn has_ptrs_index(&self) -> bool { + self.idx.pt.is_some() + } + + pub fn rollout_iter(&self) -> anyhow::Result<(RolloutIterator, ReadTx)> { + self.db.sp.store.rollout_iter() + } + + pub fn is_dirty(&self) -> bool { + self.db.sp.state.is_dirty() || self.db.pt.state.is_dirty() + } + + pub fn spaces_tip_meatadata(&mut self) -> anyhow::Result<&[u8]> { + Ok(self.db.sp.state.inner()?.metadata()) + } + + pub(crate) fn insert_spaceout(&self, key: OutpointKey, spaceout: SpaceOut) { + self.db.sp.state.insert(key, spaceout) + } + + pub(crate) fn insert_space(&self, key: SpaceKey, outpoint: EncodableOutpoint) { + self.db.sp.state.insert(key, outpoint) + } + + pub(crate) fn update_bid(&self, previous: Option, bid: BidKey, space: SpaceKey) { + if let Some(previous) = previous { + self.db.sp.state.remove(previous); + } + self.db.sp.state.insert(bid, space) + } + + pub fn remove_bid(&self, bid_key: BidKey) { + self.db.sp.state.remove(bid_key); + } + + pub fn spaces_inner(&mut self) -> anyhow::Result<&mut ReadTx> { + self.db.sp.state.inner() + } + + pub fn ptrs_tip(&self) -> ChainAnchor { + *self.db.pt.state.tip.read().expect("ptrs tip") + } + + pub fn update_ptrs_tip(&self, height: u32, block_hash: BlockHash) { + let mut tip = self.db.pt.state.tip.write().expect("write tip"); + tip.height = height; + tip.hash = block_hash; + } + + pub fn update_spaces_tip(&self, height: u32, block_hash: BlockHash) { + let mut tip = self.db.sp.state.tip.write(). + expect("write tip"); + tip.height = height; + tip.hash = block_hash; + } + + pub(crate) fn insert_ptrout(&self, key: OutpointKey, ptrout: PtrOut) { + self.db.pt.state.insert(key, ptrout) + } + + pub(crate) fn insert_ptr(&self, key: Sptr, outpoint: EncodableOutpoint) { + self.db.pt.state.insert(key, outpoint) + } + + pub(crate) fn insert_delegation(&self, key: RegistrySptrKey, space: SLabel) { + self.db.pt.state.insert_registry_delegation(key, space) + } + + pub(crate) fn insert_commitment(&self, key: CommitmentKey, commitment: Commitment) { + self.db.pt.state.insert_commitment(key, commitment) + } + + pub(crate) fn insert_registry(&self, key: RegistryKey, state_root: Hash) { + self.db.pt.state.insert_registry(key, state_root) + } + + pub fn remove_ptr_utxo(&mut self, outpoint: OutPoint) { + let key = OutpointKey::from_outpoint::(outpoint); + self.db.pt.state.remove(key) + } + + pub fn remove_delegation(&mut self, delegation: RegistrySptrKey) { + self.db.pt.state.remove(delegation) + } + + pub fn remove_space_utxo(&mut self, outpoint: OutPoint) { + let key = OutpointKey::from_outpoint::(outpoint); + self.db.sp.state.remove(key) + } + + pub fn estimate_bid(&mut self, target: usize) -> anyhow::Result { + self.db.sp.state.estimate_bid(target) + } + + pub fn get_rollout(&mut self, target: usize) -> anyhow::Result> { + self.db.sp.state.get_rollout(target) + } + + pub fn remove_space(&self, key: SpaceKey) { + self.db.sp.state.remove(key) + } + + pub fn prove_spaces_with_snapshot( + &self, + keys: &[Hash], + snapshot_block_height: u32, + ) -> anyhow::Result> { + self.db.sp.state.prove_with_snapshot(keys, snapshot_block_height) + } + + pub fn get_spaces_block(&mut self, hash: BlockHash) -> anyhow::Result> { + let idx = match &mut self.idx.sp { + None => return Err(anyhow!("spaces index must be enabled")), + Some(idx) => idx + }; + let key = BaseHash::from_slice(hash.as_ref()); + let block = idx.state.get(key).context("could not retrieve block meta")?; + Ok(block.map(|b| { + BlockMetaWithHash { + hash, + block_meta: b, + } + })) + } + + pub fn restore(&self, get_block_hash: F) -> anyhow::Result<()> + where + F: Fn(u32) -> anyhow::Result, + { + let point = self.restore_spaces(get_block_hash)?; + self.restore_ptrs(point) + } + + pub fn restore_ptrs(&self, required_checkpoint: ChainAnchor) -> anyhow::Result<()> { + let iter = self.db.pt.store.iter(); + + let mut restore_point = None; + for (idx, snapshot) in iter.enumerate() { + let snapshot = snapshot?; + let anchor: ChainAnchor = snapshot.metadata().try_into()?; + if anchor == required_checkpoint { + restore_point = Some((idx, snapshot, anchor)); + break; + } + } + + let (snapshot_idx, snapshot, checkpoint) = + match restore_point { + None => return Err(anyhow!("Could not restore ptrs to height = {}", required_checkpoint.height)), + Some(rp) => rp, + }; + + info!("Restoring ptrs block={} height={}", checkpoint.hash, checkpoint.height); + + if let Some(ptr_idx) = self.idx.pt.as_ref() { + let idx = ptr_idx.store + .iter().skip(snapshot_idx).next(); + if idx.is_none() { + return Err(anyhow!( + "Could not restore ptr block index due to missing snapshot" + )); + } + let idx = idx.unwrap()?; + let idx_checkpoint: ChainAnchor = idx.metadata().try_into()?; + if idx_checkpoint != checkpoint { + return Err(anyhow!( + "ptr block index checkpoint does not match the ptr's checkpoint" + )); + } + idx.rollback() + .context("could not rollback ptr block index snapshot")?; + } + + snapshot + .rollback() + .context("could not rollback ptr snapshot")?; + + self.db.pt.state.restore(checkpoint.clone()); + if let Some(idx) = self.idx.pt.as_ref() { + idx.state.restore(checkpoint); + } + + Ok(()) + } + + pub fn restore_spaces(&self, get_block_hash: F) -> anyhow::Result + where + F: Fn(u32) -> anyhow::Result, + { + let chain_iter = self.db.sp.store.iter(); + for (snapshot_index, snapshot) in chain_iter.enumerate() { + let chain_snapshot = snapshot?; + let chain_checkpoint: ChainAnchor = chain_snapshot.metadata().try_into()?; + let required_hash = get_block_hash(chain_checkpoint.height)?; + + if required_hash != chain_checkpoint.hash { + info!( + "Could not restore to block={} height={}", + chain_checkpoint.hash, chain_checkpoint.height + ); + continue; + } + + info!( + "Restoring block={} height={}", + chain_checkpoint.hash, chain_checkpoint.height + ); + + if let Some(block_index) = self.idx.sp.as_ref() { + let index_snapshot = block_index.store.iter().skip(snapshot_index).next(); + if index_snapshot.is_none() { + return Err(anyhow!( + "Could not restore block index due to missing snapshot" + )); + } + let index_snapshot = index_snapshot.unwrap()?; + let index_checkpoint: ChainAnchor = index_snapshot.metadata().try_into()?; + if index_checkpoint != chain_checkpoint { + return Err(anyhow!( + "block index checkpoint does not match the chain's checkpoint" + )); + } + index_snapshot + .rollback() + .context("could not rollback block index snapshot")?; + } + + chain_snapshot + .rollback() + .context("could not rollback chain snapshot")?; + + self.db.sp.state.restore(chain_checkpoint.clone()); + if let Some(block_index) = self.idx.sp.as_ref() { + block_index.state.restore(chain_checkpoint) + } + return Ok(chain_checkpoint); + } + + Err(anyhow!("Unable to restore to a valid state")) + } + + pub fn update_anchors(&self, anchors_path: &Path) -> anyhow::Result<()> { + // TODO: merge ptrs anchor + info!("Updating root anchors ..."); + let result = self + .db + .sp + .store + .update_anchors(anchors_path, ROOT_ANCHORS_COUNT) + .or_else(|e| Err(anyhow!("Could not update trust anchors: {}", e)))?; + if let Some(result) = result.first() { + info!( + "Latest root anchor {} (height: {})", + hex::encode(result.root), + result.block.height + ) + } + Ok(()) + } +} + + +fn load_sp_index(dir: &Path, genesis: ChainAnchor, tip: ChainAnchor, initial_sync: bool) -> anyhow::Result { + let block_db_path = dir.join("block_index.sdb"); + if !initial_sync && !block_db_path.exists() { + return Err(anyhow::anyhow!( + "Block index must be enabled from the initial sync." + )); + } + let block_store = SpStore::open(block_db_path)?; + let index = SpLiveStore { + state: block_store.begin(&genesis).expect("begin block index"), + store: block_store, + }; + { + let idx_tip = index.state.tip.read().expect("index"); + if idx_tip.height != tip.height || idx_tip.hash != tip.hash { + return Err(anyhow::anyhow!( + "Protocol and block index states don't match." + )); + } + } + Ok(index) +} + +fn load_pt_index(dir: &Path, genesis: ChainAnchor, tip: ChainAnchor, initial_sync: bool) -> anyhow::Result { + let block_db_path = dir.join("ptrs_block_index.sdb"); + if !initial_sync && !block_db_path.exists() { + return Err(anyhow::anyhow!( + "Ptr Block index must be enabled from the initial sync." + )); + } + let block_store = PtrStore::open(block_db_path)?; + let index = PtrLiveStore { + state: block_store.begin(&genesis).expect("begin block index"), + store: block_store, + }; + { + let idx_tip = index.state.tip.read().expect("index"); + if idx_tip.height != tip.height || idx_tip.hash != tip.hash { + return Err(anyhow::anyhow!( + "Ptrs tip and block index states don't match." + )); + } + } + Ok(index) +} diff --git a/client/src/store/mod.rs b/client/src/store/mod.rs new file mode 100644 index 0000000..32258fe --- /dev/null +++ b/client/src/store/mod.rs @@ -0,0 +1,39 @@ +use std::collections::BTreeMap; +use bincode::{Decode, Encode}; +use spacedb::db::Database; +use spacedb::{Hash, NodeHasher, Sha256Hasher}; +use spacedb::tx::{ReadTransaction, WriteTransaction}; +use spaces_protocol::bitcoin::OutPoint; + +pub mod spaces; +pub mod ptrs; +pub mod chain; + +type SpaceDb = Database; +type ReadTx = ReadTransaction; +pub type WriteTx<'db> = WriteTransaction<'db, Sha256Hasher>; +type WriteMemory = BTreeMap>>; + +pub struct Sha256; + + +#[derive(Encode, Decode)] +pub struct EncodableOutpoint(#[bincode(with_serde)] pub OutPoint); + +impl From for EncodableOutpoint { + fn from(value: OutPoint) -> Self { + Self(value) + } +} + +impl From for OutPoint { + fn from(value: EncodableOutpoint) -> Self { + value.0 + } +} + +impl spaces_protocol::hasher::KeyHasher for Sha256 { + fn hash(data: &[u8]) -> spaces_protocol::hasher::Hash { + Sha256Hasher::hash(data) + } +} diff --git a/client/src/store/ptrs.rs b/client/src/store/ptrs.rs new file mode 100644 index 0000000..ae8b7aa --- /dev/null +++ b/client/src/store/ptrs.rs @@ -0,0 +1,360 @@ +use std::{ + collections::{BTreeMap}, + fs::OpenOptions, + io, + io::ErrorKind, + mem, + path::{PathBuf}, + sync::{Arc, RwLock}, +}; + +use anyhow::{anyhow, Context, Result}; +use bincode::{config, Decode, Encode}; +use spacedb::{ + db::{Database, SnapshotIterator}, + fs::FileBackend, + subtree::SubTree, + tx::{ProofType, ReadTransaction, WriteTransaction}, + Configuration, Hash, Sha256Hasher, +}; +use spaces_protocol::{ + bitcoin::{BlockHash, OutPoint}, + constants::{ChainAnchor}, + hasher::{KeyHash, OutpointKey}, +}; +use spaces_protocol::slabel::SLabel; +use spaces_ptr::{Commitment, CommitmentKey, FullPtrOut, PtrOut, PtrSource, RegistryKey, RegistrySptrKey}; +use spaces_ptr::sptr::Sptr; +use crate::store::{EncodableOutpoint, Sha256}; + +type SpaceDb = Database; +type ReadTx = ReadTransaction; +pub type WriteTx<'db> = WriteTransaction<'db, Sha256Hasher>; +type WriteMemory = BTreeMap>>; + +#[derive(Clone)] +pub struct PtrStore(SpaceDb); + +#[derive(Clone)] +pub struct PtrLiveStore { + pub store: PtrStore, + pub state: PtrLiveSnapshot, +} + +#[derive(Clone)] +pub struct PtrLiveSnapshot { + db: SpaceDb, + pub tip: Arc>, + staged: Arc>, + snapshot: (BlockHash, ReadTx), +} + +pub struct Staged { + /// Block height of latest snapshot + snapshot_version: BlockHash, + /// Stores changes until committed + memory: WriteMemory, +} + +impl PtrStore { + pub fn open(path: PathBuf) -> Result { + let db = Self::open_db(path)?; + Ok(Self(db)) + } + + pub fn memory() -> Result { + let db = Database::memory()?; + Ok(Self(db)) + } + + fn open_db(path_buf: PathBuf) -> anyhow::Result> { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(path_buf)?; + + let config = Configuration::new().with_cache_size(1000000 /* 1MB */); + Ok(Database::new(Box::new(FileBackend::new(file)?), config)?) + } + + pub fn iter(&self) -> SnapshotIterator { + return self.0.iter(); + } + + pub fn write(&self) -> Result { + Ok(self.0.begin_write()?) + } + + pub fn begin(&self, genesis_block: &ChainAnchor) -> Result { + let snapshot = self.0.begin_read()?; + let anchor: ChainAnchor = if snapshot.metadata().len() == 0 { + genesis_block.clone() + } else { + snapshot.metadata().try_into()? + }; + + let version = anchor.hash; + let live = PtrLiveSnapshot { + db: self.0.clone(), + tip: Arc::new(RwLock::new(anchor)), + staged: Arc::new(RwLock::new(Staged { + snapshot_version: version, + memory: BTreeMap::new(), + })), + snapshot: (version, snapshot), + }; + + Ok(live) + } +} + +pub trait PtrChainState { + fn insert_ptrout(&self, key: OutpointKey, ptrout: PtrOut); + fn insert_commitment(&self, key: CommitmentKey, commitment: Commitment); + fn insert_registry(&self, key: RegistryKey, state_root: Hash); + fn insert_registry_delegation(&self, key: RegistrySptrKey, space: SLabel); + fn insert_ptr(&self, key: Sptr, outpoint: EncodableOutpoint); + + #[allow(dead_code)] + fn get_ptr_info( + &mut self, + space_hash: &Sptr, + ) -> Result>; +} + +impl PtrChainState for PtrLiveSnapshot { + fn insert_ptrout(&self, key: OutpointKey, spaceout: PtrOut) { + self.insert(key, spaceout) + } + + fn insert_commitment(&self, key: CommitmentKey, commitment: Commitment) { + self.insert(key, commitment) + } + + fn insert_registry(&self, key: RegistryKey, state_root: Hash) { + self.insert(key, state_root) + } + + fn insert_registry_delegation(&self, key: RegistrySptrKey, space: SLabel) { + self.insert(key, space) + } + + fn insert_ptr(&self, key: Sptr, outpoint: EncodableOutpoint) { + self.insert(key, outpoint) + } + + fn get_ptr_info(&mut self, hash: &Sptr) -> Result> { + let outpoint = self.get_ptr_outpoint(hash)?; + + if let Some(outpoint) = outpoint { + let spaceout = self.get_ptrout(&outpoint)?; + + return Ok(Some(FullPtrOut { + txid: outpoint.txid, + ptrout: spaceout.expect("should exist if outpoint exists"), + })); + } + Ok(None) + } +} + +impl PtrLiveSnapshot { + #[inline] + pub fn is_dirty(&self) -> bool { + self.staged.read().expect("read").memory.len() > 0 + } + + pub fn restore(&self, checkpoint: ChainAnchor) { + let snapshot_version = checkpoint.hash; + let mut meta_lock = self.tip.write().expect("write lock"); + *meta_lock = checkpoint; + + // clear all staged changes + let mut staged_lock = self.staged.write().expect("write lock"); + *staged_lock = Staged { + snapshot_version, + memory: BTreeMap::new(), + }; + } + + pub fn prove_with_snapshot( + &self, + keys: &[Hash], + snapshot_block_height: u32, + ) -> Result> { + let snapshot = self.db.iter().filter_map(|s| s.ok()).find(|s| { + let anchor: ChainAnchor = match s.metadata().try_into() { + Ok(a) => a, + _ => return false, + }; + anchor.height == snapshot_block_height + }); + if let Some(mut snapshot) = snapshot { + return snapshot + .prove(keys, ProofType::Standard) + .or_else(|err| Err(anyhow!("Could not prove: {}", err))); + } + Err(anyhow!( + "Older snapshot targeting block {} could not be found", + snapshot_block_height + )) + } + + pub fn inner(&mut self) -> anyhow::Result<&mut ReadTx> { + { + let rlock = self.staged.read().expect("acquire lock"); + let version = rlock.snapshot_version; + drop(rlock); + + self.update_snapshot(version)?; + } + Ok(&mut self.snapshot.1) + } + + pub fn insert, T: Encode>(&self, key: K, value: T) { + let value = bincode::encode_to_vec(value, config::standard()).expect("encodes value"); + self.insert_raw(key.into(), value); + } + + pub fn get, T: Decode<()>>( + &mut self, + key: K, + ) -> spacedb::Result> { + match self.get_raw(&key.into())? { + Some(value) => { + let (decoded, _): (T, _) = bincode::decode_from_slice(&value, config::standard()) + .map_err(|e| { + spacedb::Error::IO(io::Error::new(ErrorKind::Other, e.to_string())) + })?; + Ok(Some(decoded)) + } + None => Ok(None), + } + } + + pub fn remove>(&self, key: K) { + self.remove_raw(&key.into()) + } + + #[inline] + fn remove_raw(&self, key: &Hash) { + self.staged + .write() + .expect("write lock") + .memory + .insert(*key, None); + } + + #[inline] + fn insert_raw(&self, key: Hash, value: Vec) { + self.staged + .write() + .expect("write lock") + .memory + .insert(key, Some(value)); + } + + fn update_snapshot(&mut self, version: BlockHash) -> Result<()> { + if self.snapshot.0 != version { + self.snapshot.1 = self.db.begin_read().context("could not read snapshot")?; + let anchor: ChainAnchor = self.snapshot.1.metadata().try_into().map_err(|_| { + std::io::Error::new(std::io::ErrorKind::Other, "could not parse metdata") + })?; + + assert_eq!(version, anchor.hash, "inconsistent db state"); + self.snapshot.0 = version; + } + Ok(()) + } + + pub fn get_raw(&mut self, key: &Hash) -> spacedb::Result>> { + let rlock = self.staged.read().expect("acquire lock"); + + if let Some(value) = rlock.memory.get(key) { + return match value { + None => Ok(None), + Some(value) => Ok(Some(value.clone())), + }; + } + + let version = rlock.snapshot_version; + drop(rlock); + + self.update_snapshot(version).map_err(|error| { + spacedb::Error::IO(std::io::Error::new(std::io::ErrorKind::Other, error)) + })?; + self.snapshot.1.get(key) + } + + pub fn commit(&self, metadata: ChainAnchor, mut tx: WriteTx) -> Result<()> { + let mut staged = self.staged.write().expect("write"); + let changes = mem::replace( + &mut *staged, + Staged { + snapshot_version: metadata.hash, + memory: BTreeMap::new(), + }, + ); + + for (key, value) in changes.memory { + match value { + None => { + _ = { + tx = tx.delete(key)?; + } + } + Some(value) => tx = tx.insert(key, value)?, + } + } + + tx.metadata(metadata.to_vec())?; + tx.commit()?; + drop(staged); + Ok(()) + } +} + +impl PtrSource for PtrLiveSnapshot { + fn get_ptr_outpoint( + &mut self, + sptr: &Sptr, + ) -> spaces_protocol::errors::Result> { + let result: Option = self.get(*sptr).map_err(|err| { + spaces_protocol::errors::Error::IO(format!("getptroutpoint: {}", err.to_string())) + })?; + Ok(result.map(|out| out.into())) + } + + fn get_commitment(&mut self, key: &CommitmentKey) -> spaces_protocol::errors::Result> { + let result = self.get(*key).map_err(|err| { + spaces_protocol::errors::Error::IO(format!("getcommitment: {}", err.to_string())) + })?; + Ok(result) + } + + fn get_delegator(&mut self, key: &RegistrySptrKey) -> spaces_protocol::errors::Result> { + let result = self.get(*key).map_err(|err| { + spaces_protocol::errors::Error::IO(format!("getdelegate: {}", err.to_string())) + })?; + Ok(result) + } + + fn get_commitments_tip(&mut self, key: &RegistryKey) -> spaces_protocol::errors::Result> { + let result = self.get(*key).map_err(|err| { + spaces_protocol::errors::Error::IO(format!("getregistry: {}", err.to_string())) + })?; + Ok(result) + } + + fn get_ptrout( + &mut self, + outpoint: &OutPoint, + ) -> spaces_protocol::errors::Result> { + let h = OutpointKey::from_outpoint::(*outpoint); + let result = self.get(h).map_err(|err| { + spaces_protocol::errors::Error::IO(format!("getptrout: {}", err.to_string())) + })?; + Ok(result) + } +} diff --git a/client/src/store.rs b/client/src/store/spaces.rs similarity index 93% rename from client/src/store.rs rename to client/src/store/spaces.rs index e87dc3d..8a27466 100644 --- a/client/src/store.rs +++ b/client/src/store/spaces.rs @@ -17,18 +17,22 @@ use spacedb::{ db::{Database, SnapshotIterator}, fs::FileBackend, subtree::SubTree, - tx::{KeyIterator, ProofType, ReadTransaction, WriteTransaction}, - Configuration, Hash, NodeHasher, Sha256Hasher, + tx::{KeyIterator, ProofType}, + Configuration, Hash, Sha256Hasher, }; use spaces_protocol::{ bitcoin::{BlockHash, OutPoint}, constants::{ChainAnchor, ROLLOUT_BATCH_SIZE}, hasher::{BidKey, KeyHash, OutpointKey, SpaceKey}, - prepare::DataSource, + prepare::SpacesSource, Covenant, FullSpaceOut, SpaceOut, }; use crate::rpc::RootAnchor; +use crate::store::{EncodableOutpoint, ReadTx, Sha256, SpaceDb, WriteMemory, WriteTx}; + +#[derive(Clone)] +pub struct SpStore(SpaceDb); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RolloutEntry { @@ -36,24 +40,14 @@ pub struct RolloutEntry { pub value: u32, } -type SpaceDb = Database; -type ReadTx = ReadTransaction; -pub type WriteTx<'db> = WriteTransaction<'db, Sha256Hasher>; -type WriteMemory = BTreeMap>>; - -#[derive(Clone)] -pub struct Store(SpaceDb); - -pub struct Sha256; - #[derive(Clone)] -pub struct LiveStore { - pub store: Store, - pub state: LiveSnapshot, +pub struct SpLiveStore { + pub store: SpStore, + pub state: SpLiveSnapshot, } #[derive(Clone)] -pub struct LiveSnapshot { +pub struct SpLiveSnapshot { db: SpaceDb, pub tip: Arc>, staged: Arc>, @@ -67,7 +61,7 @@ pub struct Staged { memory: WriteMemory, } -impl Store { +impl SpStore { pub fn open(path: PathBuf) -> Result { let db = Self::open_db(path)?; Ok(Self(db)) @@ -129,7 +123,7 @@ impl Store { Ok(anchors) } - pub fn begin(&self, genesis_block: &ChainAnchor) -> Result { + pub fn begin(&self, genesis_block: &ChainAnchor) -> Result { let snapshot = self.0.begin_read()?; let anchor: ChainAnchor = if snapshot.metadata().len() == 0 { genesis_block.clone() @@ -138,7 +132,7 @@ impl Store { }; let version = anchor.hash; - let live = LiveSnapshot { + let live = SpLiveSnapshot { db: self.0.clone(), tip: Arc::new(RwLock::new(anchor)), staged: Arc::new(RwLock::new(Staged { @@ -152,11 +146,11 @@ impl Store { } } -pub trait ChainStore { +pub trait SpStoreUtils { fn rollout_iter(&self) -> Result<(RolloutIterator, ReadTx)>; } -impl ChainStore for Store { +impl SpStoreUtils for SpStore { fn rollout_iter(&self) -> Result<(RolloutIterator, ReadTx)> { let snapshot = self.0.begin_read()?; Ok(( @@ -169,22 +163,7 @@ impl ChainStore for Store { } } -#[derive(Encode, Decode)] -pub struct EncodableOutpoint(#[bincode(with_serde)] pub OutPoint); - -impl From for EncodableOutpoint { - fn from(value: OutPoint) -> Self { - Self(value) - } -} - -impl From for OutPoint { - fn from(value: EncodableOutpoint) -> Self { - value.0 - } -} - -pub trait ChainState { +pub trait SpacesState { fn insert_spaceout(&self, key: OutpointKey, spaceout: SpaceOut); fn insert_space(&self, key: SpaceKey, outpoint: EncodableOutpoint); @@ -196,7 +175,7 @@ pub trait ChainState { ) -> anyhow::Result>; } -impl ChainState for LiveSnapshot { +impl SpacesState for SpLiveSnapshot { fn insert_spaceout(&self, key: OutpointKey, spaceout: SpaceOut) { self.insert(key, spaceout) } @@ -227,7 +206,7 @@ impl ChainState for LiveSnapshot { } } -impl LiveSnapshot { +impl SpLiveSnapshot { #[inline] pub fn is_dirty(&self) -> bool { self.staged.read().expect("read").memory.len() > 0 @@ -482,7 +461,7 @@ impl LiveSnapshot { } } -impl DataSource for LiveSnapshot { +impl SpacesSource for SpLiveSnapshot { fn get_space_outpoint( &mut self, space_hash: &SpaceKey, @@ -505,11 +484,7 @@ impl DataSource for LiveSnapshot { } } -impl spaces_protocol::hasher::KeyHasher for Sha256 { - fn hash(data: &[u8]) -> spaces_protocol::hasher::Hash { - Sha256Hasher::hash(data) - } -} + pub struct RolloutIterator { inner: KeyIterator, diff --git a/client/src/wallets.rs b/client/src/wallets.rs index fda04a4..a9940ab 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -1,11 +1,11 @@ -use std::{collections::BTreeMap, str::FromStr, time::Duration}; - +use std::{collections::BTreeMap, fmt, str::FromStr, time::Duration}; +use std::fmt::{Display, Formatter}; use anyhow::anyhow; use clap::ValueEnum; use futures::{stream::FuturesUnordered, StreamExt}; use log::{info, warn}; use serde::{Deserialize, Serialize}; -use serde_json::json; +use serde_json::{json}; use spaces_protocol::{ bitcoin::Txid, constants::ChainAnchor, @@ -35,16 +35,86 @@ use tokio::{ sync::{broadcast, mpsc, mpsc::Receiver, oneshot}, time::Instant, }; +use spaces_protocol::bitcoin::address::ParseError; use spaces_protocol::bitcoin::Network; +use spaces_ptr::sptr::{Sptr, SptrParseError, SPTR_HRP}; +use spaces_wallet::builder::{CommitmentRequest, PtrRequest, PtrTransfer}; use crate::{calc_progress, checker::TxChecker, client::BlockSource, config::ExtendedNetwork, rpc::{RpcWalletRequest, RpcWalletTxBuilder, WalletLoadRequest}, source::{ BitcoinBlockSource, BitcoinRpc, BitcoinRpcError, BlockEvent, BlockFetchError, BlockFetcher, -}, std_wait, store::{ChainState, LiveSnapshot, Sha256}}; +}, std_wait}; use crate::cbf::{CompactFilterSync}; use crate::spaces::Spaced; +use crate::store::chain::Chain; +use crate::store::Sha256; const MEMPOOL_CHECK_INTERVAL: Duration = Duration::from_secs(if cfg!(debug_assertions) { 1 } else { 5 * 60 }); +#[derive(Debug, Clone)] +pub enum ResolvableTarget { + Space(SLabel), + SpaceAddress(SpaceAddress), + Address(Address), + Sptr(Sptr), +} + +#[derive(Debug)] +pub enum ResolvableTargetParseError { + SpaceLabelParseError(spaces_protocol::errors::Error), + AddressParseError(ParseError), + SptrParseError(SptrParseError), +} + +impl Display for ResolvableTargetParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + ResolvableTargetParseError::SpaceLabelParseError(e) => write!(f, "{}", e), + ResolvableTargetParseError::AddressParseError(e) => write!(f, "{}", e), + ResolvableTargetParseError::SptrParseError(e) => write!(f, "{}", e), + } + } +} + +impl std::error::Error for ResolvableTargetParseError {} + +impl FromStr for ResolvableTarget { + type Err = ResolvableTargetParseError; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + if let Some(rest) = s.strip_prefix('@') { + return SLabel::from_str(rest) + .map(ResolvableTarget::Space) + .map_err(ResolvableTargetParseError::SpaceLabelParseError); + } + if s.starts_with(SPTR_HRP) { + return Sptr::from_str(s) + .map(ResolvableTarget::Sptr) + .map_err(ResolvableTargetParseError::SptrParseError); + } + + match SpaceAddress::from_str(s) { + Ok(addr) => Ok(ResolvableTarget::SpaceAddress(addr)), + Err(_) => + Address::from_str(s) + .map(|addr| + ResolvableTarget::Address(addr.assume_checked())) + .map_err(ResolvableTargetParseError::AddressParseError) + } + } +} + +impl fmt::Display for ResolvableTarget { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ResolvableTarget::Space(label) => write!(f, "{}", label), + ResolvableTarget::Sptr(sptr) => write!(f, "{}", sptr), + ResolvableTarget::SpaceAddress(addr) => write!(f, "{}", addr), + ResolvableTarget::Address(addr) => write!(f, "{}", addr), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TxResponse { #[serde(skip_serializing_if = "Option::is_none")] @@ -83,12 +153,12 @@ pub enum WalletStatus { pub struct WalletProgressUpdate { pub status: WalletStatus, #[serde(skip_serializing_if = "Option::is_none")] - pub progress: Option + pub progress: Option, } impl WalletProgressUpdate { pub fn new(status: WalletStatus, progress: Option) -> Self { - Self { status, progress } + Self { status, progress } } } @@ -261,7 +331,7 @@ impl RpcWallet { fn handle_buy( source: &BitcoinBlockSource, - state: &mut LiveSnapshot, + chain: &mut Chain, wallet: &mut SpacesWallet, listing: Listing, skip_tx_check: bool, @@ -276,7 +346,7 @@ impl RpcWallet { }; info!("Using fee rate: {} sat/vB", fee_rate.to_sat_per_vb_ceil()); - let (_, fullspaceout) = SpacesWallet::verify_listing::(state, &listing)?; + let (_, fullspaceout) = SpacesWallet::verify_listing::(chain, &listing)?; let space = fullspaceout .spaceout @@ -286,11 +356,11 @@ impl RpcWallet { .name .to_string(); let previous_spaceout = fullspaceout.outpoint(); - let tx = wallet.buy::(state, &listing, fee_rate)?; + let tx = wallet.buy::(chain, &listing, fee_rate)?; if !skip_tx_check { let tip = wallet.local_chain().tip().height(); - let mut checker = TxChecker::new(state); + let mut checker = TxChecker::new(chain); checker.check_apply_tx(tip + 1, &tx)?; } @@ -324,13 +394,13 @@ impl RpcWallet { fn handle_fee_bump( source: &BitcoinBlockSource, - state: &mut LiveSnapshot, + chain: &mut Chain, wallet: &mut SpacesWallet, txid: Txid, skip_tx_check: bool, fee_rate: FeeRate, ) -> anyhow::Result> { - let unspendables = wallet.list_spaces_outpoints(state)?; + let unspendables = wallet.list_spaces_outpoints(chain)?; let tx_events = wallet.get_tx_events(txid)?; let builder = wallet.build_fee_bump(unspendables, txid, fee_rate)?; @@ -339,7 +409,7 @@ impl RpcWallet { if !skip_tx_check { let tip = wallet.local_chain().tip().height(); - let mut checker = TxChecker::new(state); + let mut checker = TxChecker::new(chain); checker.check_apply_tx(tip + 1, &replacement)?; } @@ -366,41 +436,18 @@ impl RpcWallet { fn handle_force_spend_output( _source: &BitcoinBlockSource, - _state: &mut LiveSnapshot, + _chain: &mut Chain, _wallet: &mut SpacesWallet, _output: OutPoint, _fee_rate: FeeRate, ) -> anyhow::Result { todo!("") - // let coin_selection = Self::get_spaces_coin_selection(wallet, state, true)?; - // let addre = wallet.spaces.next_unused_address(KeychainKind::External); - // let mut builder = wallet.spaces.build_tx().coin_selection(coin_selection); - // - // builder.ordering(TxOrdering::Untouched); - // builder.fee_rate(fee_rate); - // builder.add_utxo(output)?; - // builder.add_recipient(addre.script_pubkey(), Amount::from_sat(5000)); - // - // let psbt = builder.finish()?; - // let tx = wallet.sign(psbt, None)?; - // - // let txid = tx.compute_txid(); - // let last_seen = source.rpc.broadcast_tx(&source.client, &tx)?; - // wallet.apply_unconfirmed_tx(tx, last_seen); - // wallet.commit()?; - // - // Ok(TxResponse { - // txid, - // events: vec![], - // error: None, - // raw: None, - // }) } fn wallet_handle_commands( network: ExtendedNetwork, source: &BitcoinBlockSource, - mut state: &mut LiveSnapshot, + mut chain: &mut Chain, wallet: &mut SpacesWallet, command: WalletCommand, progress_update: WalletProgressUpdate, @@ -428,7 +475,9 @@ impl RpcWallet { _ = resp.send(Err(anyhow::anyhow!("Wallet is syncing"))); return Ok(()); } - let batch_result = Self::batch_tx(network, &source, wallet, &mut state, request); + let batch_result = Self::batch_tx( + network, &source, wallet, chain, request, + ); _ = resp.send(batch_result); } WalletCommand::BumpFee { @@ -443,7 +492,7 @@ impl RpcWallet { } let result = Self::handle_fee_bump( source, - &mut state, + &mut chain, wallet, txid, skip_tx_check, @@ -457,7 +506,7 @@ impl RpcWallet { resp, } => { let result = - Self::handle_force_spend_output(source, &mut state, wallet, outpoint, fee_rate); + Self::handle_force_spend_output(source, chain, wallet, outpoint, fee_rate); _ = resp.send(result); } WalletCommand::GetNewAddress { kind, resp } => { @@ -471,14 +520,14 @@ impl RpcWallet { _ = resp.send(Ok(address)); } WalletCommand::ListUnspent { resp } => { - _ = resp.send(wallet.list_unspent_with_details(state)); + _ = resp.send(wallet.list_unspent_with_details(chain)); } WalletCommand::ListTransactions { count, skip, resp } => { let transactions = Self::list_transactions(wallet, count, skip); _ = resp.send(transactions); } WalletCommand::ListSpaces { resp } => { - let result = Self::list_spaces(wallet, state); + let result = Self::list_spaces(wallet, chain); _ = resp.send(result); } WalletCommand::ListBidouts { resp } => { @@ -504,7 +553,7 @@ impl RpcWallet { } => { _ = resp.send(Self::handle_buy( source, - state, + chain, wallet, listing, skip_tx_check, @@ -512,10 +561,10 @@ impl RpcWallet { )); } WalletCommand::Sell { space, price, resp } => { - _ = resp.send(wallet.sell::(state, &space, Amount::from_sat(price))); + _ = resp.send(wallet.sell::(chain, &space, Amount::from_sat(price))); } WalletCommand::SignEvent { space, event, resp } => { - _ = resp.send(wallet.sign_event::(state, &space, event)); + _ = resp.send(wallet.sign_event::(chain, &space, event)); } } Ok(()) @@ -524,9 +573,9 @@ impl RpcWallet { /// Returns true if Bitcoin, protocol, and wallet tips match. fn all_synced( bitcoin: &BitcoinBlockSource, - protocol: &mut LiveSnapshot, + protocol: &mut Chain, wallet: &SpacesWallet, - progress: Option<&mut WalletProgressUpdate> + progress: Option<&mut WalletProgressUpdate>, ) -> Option { let wallet_tip = wallet.local_chain().tip(); @@ -537,13 +586,7 @@ impl RpcWallet { return None; } }; - let protocol_tip = match protocol.tip.read() { - Ok(tip) => tip.clone(), - Err(e) => { - warn!("Failed to read protocol tip: {}", e); - return None; - } - }; + let protocol_tip = protocol.tip(); if info.headers_synced.is_some_and(|synced| !synced) || info.headers == 0 || @@ -558,11 +601,10 @@ impl RpcWallet { // Bitcoin syncing if info.headers != info.blocks { if let Some(p) = progress { - *p = WalletProgressUpdate::new(WalletStatus::ChainSync, Some(calc_progress( info.checkpoint.map(|c| c.height).unwrap_or(0), info.blocks, - info.headers + info.headers, ))); } return None; @@ -571,18 +613,18 @@ impl RpcWallet { if protocol_tip.height != info.headers { if let Some(p) = progress { let network = match wallet.config.network { - Network::Bitcoin =>ExtendedNetwork::Mainnet, - Network::Testnet =>ExtendedNetwork::Testnet4, + Network::Bitcoin => ExtendedNetwork::Mainnet, + Network::Testnet => ExtendedNetwork::Testnet4, Network::Signet => ExtendedNetwork::Signet, _ => ExtendedNetwork::Regtest, }; let start = Spaced::genesis(network); *p = WalletProgressUpdate::new( WalletStatus::SpacesSync, - Some(calc_progress(start.height, protocol_tip.height, info.headers)) + Some(calc_progress(start.height, protocol_tip.height, info.headers)), ); } - return None + return None; } if protocol_tip.hash == wallet_tip.hash() && protocol_tip.hash == info.best_block_hash { @@ -598,16 +640,18 @@ impl RpcWallet { fn wallet_sync( network: ExtendedNetwork, source: BitcoinBlockSource, - mut state: LiveSnapshot, + mut chain: Chain, mut wallet: SpacesWallet, mut commands: Receiver, shutdown: broadcast::Sender<()>, num_workers: usize, cbf: bool, ) -> anyhow::Result<()> { - let (fetcher, receiver) = BlockFetcher::new(network.fallback_network(), - source.clone(), - num_workers); + let (fetcher, receiver) = BlockFetcher::new( + network.fallback_network(), + source.clone(), + num_workers, + ); let mut wallet_tip = { let tip = wallet.local_chain().tip(); @@ -630,7 +674,7 @@ impl RpcWallet { let mut last_mempool_check = Instant::now(); let mut wallet_progress = WalletProgressUpdate::new( WalletStatus::Syncing, - None + None, ); loop { @@ -643,14 +687,14 @@ impl RpcWallet { if let Ok(command) = commands.try_recv() { let _ = Self::all_synced( &source, - &mut state, &wallet, - Some(&mut wallet_progress) + &mut chain, &wallet, + Some(&mut wallet_progress), ).is_some(); Self::wallet_handle_commands( network, &source, - &mut state, + &mut chain, &mut wallet, command, wallet_progress, @@ -787,9 +831,9 @@ impl RpcWallet { } if synced_at_least_once && last_mempool_check.elapsed() > MEMPOOL_CHECK_INTERVAL { - if let Some(common_tip) = Self::all_synced(&source, &mut state, &wallet, None) { + if let Some(common_tip) = Self::all_synced(&source, &mut chain, &wallet, None) { let mem = MempoolChecker(&source); - match wallet.update_unconfirmed_bids(mem, common_tip.height, &mut state) { + match wallet.update_unconfirmed_bids(mem, common_tip.height, &mut chain) { Ok(txids) => { for txid in txids { info!("Dropped {} - no longer in the mempool", txid); @@ -812,9 +856,9 @@ impl RpcWallet { fn list_spaces( wallet: &mut SpacesWallet, - state: &mut LiveSnapshot, + chain: &mut Chain, ) -> anyhow::Result { - let unspent = wallet.list_unspent_with_details(state)?; + let unspent = wallet.list_unspent_with_details(chain)?; let recent_events = wallet.list_recent_events()?; let mut pending = vec![]; @@ -835,7 +879,7 @@ impl RpcWallet { } 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)?; + let space = chain.get_space_info(&spacehash)?; if let Some(space) = space { if space.spaceout.space.as_ref().unwrap().is_owned() { continue; @@ -937,39 +981,42 @@ impl RpcWallet { fn resolve( network: ExtendedNetwork, - store: &mut LiveSnapshot, + chain: &mut Chain, to: &str, require_space_address: bool, ) -> anyhow::Result> { - if let Ok(address) = Address::from_str(to) { - if require_space_address { - return Err(anyhow!("recipient must be a space address")); + let target = ResolvableTarget::from_str(to)?; + let address = match target { + ResolvableTarget::Address(address) => { + if require_space_address { + return Err(anyhow!("recipient must be a space address")); + } + address } - return Ok(Some(address.require_network(network.fallback_network())?)); - } - if let Ok(space_address) = SpaceAddress::from_str(to) { - return Ok(Some(space_address.0)); - } - - let sname = match SLabel::from_str(to) { - Ok(sname) => sname, - Err(_) => { - return Err(anyhow!( - "recipient must be a valid space name prefixed with @ or an address" - )); + ResolvableTarget::Space(sname) => { + let spacehash = SpaceKey::from(Sha256::hash(sname.as_ref())); + let script_pubkey = match chain.get_space_info(&spacehash)? { + None => return Ok(None), + Some(fullspaceout) => fullspaceout.spaceout.script_pubkey, + }; + Address::from_script( + script_pubkey.as_script(), + network.fallback_network(), + )? + } + ResolvableTarget::SpaceAddress(address) => address.0, + ResolvableTarget::Sptr(sptr) => { + let script_pubkey = match chain.get_ptr_info(&sptr)? { + None => return Ok(None), + Some(fullptrout) => fullptrout.ptrout.script_pubkey, + }; + Address::from_script( + script_pubkey.as_script(), + network.fallback_network(), + )? } }; - - let spacehash = SpaceKey::from(Sha256::hash(sname.as_ref())); - let script_pubkey = match store.get_space_info(&spacehash)? { - None => return Ok(None), - Some(fullspaceout) => fullspaceout.spaceout.script_pubkey, - }; - - Ok(Some(Address::from_script( - script_pubkey.as_script(), - network.fallback_network(), - )?)) + Ok(Some(address)) } fn replaces_unconfirmed_bid(wallet: &SpacesWallet, bid_spaceout: &FullSpaceOut) -> bool { @@ -989,7 +1036,7 @@ impl RpcWallet { network: ExtendedNetwork, source: &BitcoinBlockSource, wallet: &mut SpacesWallet, - store: &mut LiveSnapshot, + chain: &mut Chain, tx: RpcWalletTxBuilder, ) -> anyhow::Result { let tip_height = wallet.local_chain().tip().height(); @@ -1027,7 +1074,7 @@ impl RpcWallet { for req in tx.requests { match req { RpcWalletRequest::SendCoins(params) => { - let recipient = match Self::resolve(network, store, ¶ms.to, false)? { + let recipient = match Self::resolve(network, chain, ¶ms.to, false)? { None => return Err(anyhow!("send: could not resolve '{}'", params.to)), Some(r) => r, }; @@ -1047,7 +1094,7 @@ impl RpcWallet { } let recipient = if let Some(to) = params.to { - match Self::resolve(network, store, &to, true)? { + match Self::resolve(network, chain, &to, true)? { None => return Err(anyhow!("transfer: could not resolve '{}'", to)), Some(r) => Some(r), } @@ -1057,7 +1104,7 @@ impl RpcWallet { for space in spaces { let spacehash = SpaceKey::from(Sha256::hash(space.as_ref())); - match store.get_space_info(&spacehash)? { + match chain.get_space_info(&spacehash)? { None => return Err(anyhow!("transfer: you don't own `{}`", space)), Some(full) if full.spaceout.space.is_none() @@ -1099,7 +1146,7 @@ impl RpcWallet { if !tx.force { // Warn if already exists let spacehash = SpaceKey::from(Sha256::hash(name.as_ref())); - let spaceout = store.get_space_info(&spacehash)?; + let spaceout = chain.get_space_info(&spacehash)?; if spaceout.is_some() { return Err(anyhow!("open '{}': space already exists", params.name)); } @@ -1110,7 +1157,7 @@ impl RpcWallet { RpcWalletRequest::Bid(params) => { let name = SLabel::from_str(¶ms.name)?; let spacehash = SpaceKey::from(Sha256::hash(name.as_ref())); - let spaceout = store.get_space_info(&spacehash)?; + let spaceout = chain.get_space_info(&spacehash)?; if spaceout.is_none() { return Err(anyhow!("bid '{}': space does not exist", params.name)); } @@ -1125,7 +1172,7 @@ impl RpcWallet { RpcWalletRequest::Register(params) => { let name = SLabel::from_str(¶ms.name)?; let spacehash = SpaceKey::from(Sha256::hash(name.as_ref())); - let spaceout = store.get_space_info(&spacehash)?; + let spaceout = chain.get_space_info(&spacehash)?; if spaceout.is_none() { return Err(anyhow!("register '{}': space does not exist", params.name)); } @@ -1185,7 +1232,7 @@ impl RpcWallet { for space in params.context.iter() { let name = SLabel::from_str(&space)?; let spacehash = SpaceKey::from(Sha256::hash(name.as_ref())); - let spaceout = store.get_space_info(&spacehash)?; + let spaceout = chain.get_space_info(&spacehash)?; if spaceout.is_none() { return Err(anyhow!("script '{}': space does not exist", space)); } @@ -1211,12 +1258,58 @@ impl RpcWallet { let script = SpaceScript::nop_script(params.space_script); builder = builder.add_execute(spaces, script); } + RpcWalletRequest::TransferPtr(params) => { + let recipient = match Self::resolve(network, chain, ¶ms.to, true)? { + None => return Err(anyhow!("transferptr: could not resolve '{}'", params.to)), + Some(r) => r, + }; + for sptr in params.ptrs { + let ptr = match chain.get_ptr_info(&sptr)? { + None => return Err(anyhow!("transferptr: you don't own `{}`", sptr)), + Some(full) => full, + }; + builder = builder.add_ptr_transfer(PtrTransfer { + ptr, + recipient: SpaceAddress::from(recipient.clone()), + }); + } + } + RpcWalletRequest::CreatePtr(params) => { + let addr = Address::from_str(¶ms.address) + .map_err(|e| anyhow!("transferptr: invalid address for ptr {}: {:?}", params.address, e))?; + builder = builder.add_ptr(PtrRequest { + ptr: addr, + }) + } + RpcWalletRequest::Commit(params) => { + let space_key = SpaceKey::from(Sha256::hash(params.space.as_ref())); + let info = match chain.get_space_info(&space_key)? { + None => return Err(anyhow!("commit: no such space {}", params.space)), + Some(info) => info, + }; + let sptr = Sptr::from_spk::(info.spaceout.script_pubkey.clone()); + let ptr_info = match chain.get_ptr_info(&sptr)? { + None => return Err(anyhow!("commit: sptr {} doesn't exists for space {} - have you created it?", sptr, params.space)), + Some(pt) => pt, + }; + if info.spaceout.space.is_none() + || !info.spaceout.space.as_ref().unwrap().is_owned() + || !wallet.is_mine(ptr_info.ptrout.script_pubkey.clone()) + { + return Err(anyhow!("commit: you don't control `{}`", sptr)); + } + + builder = builder.add_commitment(CommitmentRequest { + ptrout: ptr_info, + root: *params.root.as_ref() + }) + } } } - let unspendables = wallet.list_spaces_outpoints(store)?; + let unspendables = wallet.list_spaces_outpoints(chain)?; let median_time = source.get_median_time()?; - let mut checker = TxChecker::new(store); + let mut checker = TxChecker::new(chain); if !tx.skip_tx_check { let mut unconfirmed: Vec<_> = wallet @@ -1326,7 +1419,7 @@ impl RpcWallet { pub async fn service( network: ExtendedNetwork, rpc: BitcoinRpc, - store: LiveSnapshot, + chain: Chain, mut channel: Receiver, shutdown: broadcast::Sender<()>, num_workers: usize, @@ -1344,7 +1437,7 @@ impl RpcWallet { wallet = channel.recv() => { if let Some( loaded ) = wallet { let wallet_name = loaded.export.label.clone(); - let wallet_chain = store.clone(); + let wallet_chain = chain.clone(); let rpc = rpc.clone(); let wallet_shutdown = shutdown.clone(); let (tx, rx) = oneshot::channel(); diff --git a/client/tests/ptr_tests.rs b/client/tests/ptr_tests.rs new file mode 100644 index 0000000..a5eec3c --- /dev/null +++ b/client/tests/ptr_tests.rs @@ -0,0 +1,357 @@ +use std::{path::PathBuf, str::FromStr}; +use anyhow::anyhow; +use spaces_client::{ + rpc::{ + RpcClient, RpcWalletRequest, + RpcWalletTxBuilder, + }, + wallets::{AddressKind, WalletResponse}, +}; +use spaces_client::rpc::{CommitParams, CreatePtrParams, TransferPtrParams, TransferSpacesParams}; +use spaces_client::store::Sha256; +use spaces_protocol::{bitcoin, bitcoin::{FeeRate}}; +use spaces_protocol::bitcoin::hashes::{sha256, Hash}; +use spaces_ptr::sptr::Sptr; +use spaces_ptr::transcript_hash; +use spaces_testutil::TestRig; +use spaces_wallet::{export::WalletExport}; +use spaces_wallet::address::SpaceAddress; +use spaces_wallet::bitcoin::{Network, ScriptBuf}; + +const ALICE: &str = "wallet_99"; +const BOB: &str = "wallet_98"; +const EVE: &str = "wallet_93"; + +async fn it_should_create_sptrs(rig: &TestRig) -> anyhow::Result<()> { + + println!("sptr example: {}", Sptr::from_spk::(ScriptBuf::from(hex::decode("5120d3c3196cb3ed7fa79c882ed62f8e5942e546130d5ae5983da67dbb6c9bdd2e79").expect("valid")))); + + rig.wait_until_wallet_synced(ALICE).await?; + + // 1) Create ptr bound to addr0 (spk0) + let addr0 = rig.spaced.client.wallet_get_new_address(ALICE, AddressKind::Coin).await?; + let create0 = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreatePtr(CreatePtrParams { address: addr0.clone() })], + false, + ).await.expect("CreatePtr addr0"); + assert!(wallet_res_err(&create0).is_ok(), "CreatePtr(addr0) must not error"); + + rig.mine_blocks(1, None).await?; + rig.wait_until_synced().await?; + rig.wait_until_wallet_synced(ALICE).await?; + + let spk0 = bitcoin::address::Address::from_str(&addr0) + .expect("valid addr0") + .assume_checked() + .script_pubkey(); + let sptr0 = Sptr::from_spk::(spk0.clone()); + + let ptr0 = rig.spaced.client.get_ptr(sptr0).await? + .expect("ptr must exist after first CreatePtr"); + let bound_spk_before = ptr0.ptrout.script_pubkey.clone(); + + // 2) Transfer ptr to addr1 (binding should change to spk1) + let addr1 = rig.spaced.client.wallet_get_new_address(BOB, AddressKind::Space).await?; + let xfer = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::TransferPtr(TransferPtrParams { + ptrs: vec![sptr0], + to: addr1.clone(), + })], + false, + ).await.expect("TransferPtr to addr1"); + assert!(wallet_res_err(&xfer).is_ok(), "TransferPtr must not error"); + + rig.mine_blocks(1, None).await?; + rig.wait_until_wallet_synced(ALICE).await?; + rig.wait_until_wallet_synced(BOB).await?; + rig.wait_until_synced().await?; + + + let spk1 = SpaceAddress::from_str(&addr1) + .expect("valid addr1") + .script_pubkey(); + + let ptr_after_xfer = rig.spaced.client.get_ptr(sptr0).await? + .expect("ptr must still resolve after transfer"); + let bound_spk_after = ptr_after_xfer.ptrout.script_pubkey.clone(); + + assert_ne!(bound_spk_before, bound_spk_after, "binding must change after transfer"); + assert_eq!(bound_spk_after, spk1, "binding must equal new destination spk"); + + // 3) Duplicate CreatePtr on ORIGINAL addr0 → tx is produced but MUST NOT overwrite binding + let dup = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreatePtr(CreatePtrParams { address: addr0.clone() })], + false, + ).await.expect("duplicate CreatePtr(addr0)"); + assert!(wallet_res_err(&dup).is_ok(), "duplicate CreatePtr should not error"); + assert!(!dup.result.is_empty(), "protocol still emits a tx for duplicate CreatePtr"); + + rig.mine_blocks(1, None).await?; + rig.wait_until_synced().await?; + rig.wait_until_wallet_synced(ALICE).await?; + + let ptr_after_dup = rig.spaced.client.get_ptr(sptr0).await? + .expect("ptr must still resolve after duplicate"); + let bound_spk_final = ptr_after_dup.ptrout.script_pubkey.clone(); + + assert_eq!( + bound_spk_final, spk1, + "duplicate CreatePtr(addr0) must be ignored: binding stays at spk1" + ); + assert_ne!( + bound_spk_final, spk0, + "binding must not be overwritten back to original spk0" + ); + + Ok(()) +} + +async fn it_should_operate_space(rig: &TestRig) -> anyhow::Result<()> { + rig.wait_until_synced().await?; + rig.wait_until_wallet_synced(ALICE).await?; + + // Pick any space Alice already OWNS. + let alice_spaces = rig.spaced.client.wallet_list_spaces(ALICE).await?; + let owned = alice_spaces + .owned + .first() + .cloned() + .expect("Alice should own at least one space for this test"); + let space_name = owned + .spaceout + .space + .as_ref() + .expect("space must exist") + .name + .to_string(); + + // Fetch full space and capture its current scriptPubKey (will be used for SPTR + address). + let full_before = rig + .spaced + .client + .get_space(&space_name) + .await? + .expect("space must exist"); + + let current_spk = full_before.spaceout.script_pubkey.clone(); + let res = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Transfer(TransferSpacesParams { + spaces: vec![space_name.clone()], + to: None, + })], + false, + ) + .await + .expect("send transfer-to-same-address (renewal)"); + + assert!(wallet_res_err(&res).is_ok(), "tx should not error"); + + // Confirm renewal. + rig.mine_blocks(1, None).await?; + rig.wait_until_synced().await?; + rig.wait_until_wallet_synced(ALICE).await?; + + let full_after = rig + .spaced + .client + .get_space(&space_name) + .await? + .expect("space must still exist"); + let spk_after = full_after.spaceout.script_pubkey.clone(); + + // Address/script must be identical after renewal. + assert_eq!(current_spk, spk_after, "space spk must remain the same after renewal"); + + // --- Create/bind an SPTR using the SAME scriptPubKey as the space --- + let space_addr = bitcoin::address::Address::from_script(¤t_spk, Network::Regtest) + .expect("derive address from space spk") + .to_string(); + + let create_ptr_res = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::CreatePtr(CreatePtrParams { + address: space_addr.clone(), + })], + false, + ) + .await + .expect("send CreatePtr to space address"); + + assert!(wallet_res_err(&create_ptr_res).is_ok(), "CreatePtr tx should not error"); + + + // Confirm ptr binding. + rig.mine_blocks(1, None).await?; + rig.wait_until_synced().await?; + rig.wait_until_wallet_synced(ALICE).await?; + + // Compute SPTR from the (unchanged) space spk and verify it's indexed. + let sptr = Sptr::from_spk::(current_spk.clone()); + let ptr_out = rig.spaced.client.get_ptr(sptr).await?; + assert!(ptr_out.is_some(), "ptr lookup by SPTR should return a result for the space spk"); + + + let space = full_after.spaceout.space.expect("space"); + let delegation = rig.spaced.client.get_delegation(space.name.clone()).await?; + assert_eq!(delegation, Some(sptr), "expected a delegation matching sptr"); + + let delegator = rig.spaced.client.get_delegator(sptr).await?; + assert_eq!(delegator, Some(space.name.clone()), "expected a delegator to match sptr"); + + let commitments_tip = rig.spaced.client.get_commitment(space.name.clone(), None) + .await.expect("commitment tip"); + assert!(commitments_tip.is_none(), "no initial commitment was made"); + + // Make a commitment + let create_commitment_res = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Commit(CommitParams { + space: space.name.clone(), + root: sha256::Hash::from_slice(&[1u8;32]).expect("valid"), + })], + false, + ) + .await + .expect("commits"); + + assert!(wallet_res_err(&create_commitment_res).is_ok(), "commit tx should not error"); + + + // Confirm commitment. + rig.mine_blocks(1, None).await?; + rig.wait_until_synced().await?; + rig.wait_until_wallet_synced(ALICE).await?; + + let commitments_tip = rig.spaced.client.get_commitment(space.name.clone(), None) + .await.expect("commitment tip"); + assert!(commitments_tip.is_some(), "one commitment was made"); + let commitment = commitments_tip.unwrap(); + + assert_eq!(commitment.state_root, [1u8;32], "there was a commitment"); + assert!(commitment.prev_root.is_none(), "no previous root"); + assert_eq!(commitment.history_hash, [1u8;32], "history hash must match initial commitment"); + + + // Make another commitment + let create_commitment_res = wallet_do( + rig, + ALICE, + vec![RpcWalletRequest::Commit(CommitParams { + space: space.name.clone(), + root: sha256::Hash::from_slice(&[2u8;32]).expect("valid"), + })], + false, + ) + .await + .expect("commits"); + + assert!(wallet_res_err(&create_commitment_res).is_ok(), "commit tx should not error"); + + // Confirm commitment. + rig.mine_blocks(1, None).await?; + rig.wait_until_synced().await?; + rig.wait_until_wallet_synced(ALICE).await?; + + let commitments_tip = rig.spaced.client.get_commitment(space.name.clone(), None) + .await.expect("commitment tip"); + let commitment = commitments_tip.unwrap(); + + assert_eq!(commitment.state_root, [2u8;32], "tip must point to most recent commitment"); + assert_eq!(commitment.prev_root.clone(), Some([1u8;32]) , "prev should point to preview commitment"); + assert_eq!(commitment.history_hash, transcript_hash::([1u8;32], [2u8;32]), + "history hash must commit to all commitments"); + + + let prev_commit = + rig.spaced.client.get_commitment( + space.name.clone(), + Some(sha256::Hash::from_slice( + commitment.prev_root.as_ref().expect("exists") + ).expect("valid")) + ).await.expect("prev_commitment"); + + assert_eq!( + prev_commit.map(|p| p.state_root), + Some([1u8;32]), + "previous commitment should continue to exist" + ); + + Ok(()) +} + +fn wallet_res_err(res: &WalletResponse) -> anyhow::Result<()> { + for tx in &res.result { + if let Some(e) = tx.error.as_ref() { + let s = e.iter() + .map(|(k, v)| format!("{k}:{v}")) + .collect::>() + .join(", "); + return Err(anyhow!("{}", s)); + } + } + Ok(()) +} + +#[tokio::test] +async fn run_ptr_tests() -> anyhow::Result<()> { + let rig = TestRig::new_with_regtest_preset().await?; + let wallets_path = rig.testdata_wallets_path().await; + + let count = rig.get_block_count().await? as u32; + assert!(count > 3000, "expected an initialized test set"); + + rig.wait_until_synced().await?; + load_wallet(&rig, wallets_path.clone(), ALICE).await?; + load_wallet(&rig, wallets_path.clone(), BOB).await?; + load_wallet(&rig, wallets_path, EVE).await?; + + it_should_create_sptrs(&rig) + .await + .expect("should open auction"); + + it_should_operate_space(&rig).await.expect("should operate space"); + Ok(()) +} + +pub async fn load_wallet(rig: &TestRig, wallets_dir: PathBuf, name: &str) -> anyhow::Result<()> { + let wallet_path = wallets_dir.join(format!("{name}.json")); + let json = std::fs::read_to_string(wallet_path)?; + let export = WalletExport::from_str(&json)?; + rig.spaced.client.wallet_import(export).await?; + Ok(()) +} + +async fn wallet_do( + rig: &TestRig, + wallet: &str, + requests: Vec, + force: bool, +) -> anyhow::Result { + let res = rig + .spaced + .client + .wallet_send_request( + wallet, + RpcWalletTxBuilder { + bidouts: None, + requests, + fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), + dust: None, + force, + confirmed_only: false, + skip_tx_check: false, + }, + ) + .await?; + Ok(res) +} diff --git a/protocol/src/prepare.rs b/protocol/src/prepare.rs index 9054696..1a27080 100644 --- a/protocol/src/prepare.rs +++ b/protocol/src/prepare.rs @@ -27,6 +27,7 @@ pub struct TxContext { pub auctioned_output: Option, } +#[derive(Clone)] pub struct InputContext { pub n: usize, pub sstxo: SSTXO, @@ -34,6 +35,7 @@ pub struct InputContext { } /// Spent Spaces Transaction Output +#[derive(Clone)] pub struct SSTXO { pub previous_output: SpaceOut, } @@ -46,7 +48,7 @@ pub struct AuctionedOutput { pub bid_psbt: BidPsbt, } -pub trait DataSource { +pub trait SpacesSource { fn get_space_outpoint(&mut self, space_hash: &SpaceKey) -> Result>; fn get_spaceout(&mut self, outpoint: &OutPoint) -> Result>; @@ -54,7 +56,7 @@ pub trait DataSource { impl TxContext { #[inline(always)] - pub fn spending_spaces(src: &mut T, tx: &Transaction) -> Result { + pub fn spending_spaces(src: &mut T, tx: &Transaction) -> Result { for input in tx.input.iter() { if src.get_spaceout(&input.previous_output)?.is_some() { return Ok(true); @@ -69,7 +71,7 @@ impl TxContext { /// /// Returns `Some(PreparedTransaction)` if the transaction is relevant to the Spaces protocol. /// Returns `None` if the transaction is not relevant. - pub fn from_tx( + pub fn from_tx( src: &mut T, tx: &Transaction, ) -> Result> { diff --git a/protocol/src/script.rs b/protocol/src/script.rs index b2f77b8..0a53b36 100644 --- a/protocol/src/script.rs +++ b/protocol/src/script.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::{ hasher::{KeyHasher, SpaceKey}, - prepare::DataSource, + prepare::SpacesSource, slabel::{SLabel, SLabelRef}, validate::RejectParams, FullSpaceOut, @@ -100,7 +100,7 @@ impl SpaceScript { .push_opcode(OP_DROP) } - pub fn eval( + pub fn eval( src: &mut T, script: &Script, ) -> crate::errors::Result>> { @@ -129,7 +129,7 @@ impl SpaceScript { } } - fn op_open( + fn op_open( src: &mut T, op_data: &[u8], ) -> crate::errors::Result> { @@ -208,7 +208,7 @@ mod tests { use crate::{ hasher::{Hash, KeyHasher, SpaceKey}, - prepare::DataSource, + prepare::SpacesSource, script::{OpenHistory, ScriptError, SpaceScript, MAGIC, MAGIC_LEN, OP_OPEN}, slabel::SLabel, Covenant, FullSpaceOut, Space, SpaceOut, @@ -260,7 +260,7 @@ mod tests { ); } } - impl DataSource for DummySource { + impl SpacesSource for DummySource { fn get_space_outpoint( &mut self, space_hash: &SpaceKey, diff --git a/protocol/src/slabel.rs b/protocol/src/slabel.rs index 4349309..39c8866 100644 --- a/protocol/src/slabel.rs +++ b/protocol/src/slabel.rs @@ -1,4 +1,3 @@ -#[cfg(feature = "serde")] use alloc::string::ToString; use alloc::{string::String, vec::Vec}; use core::{ diff --git a/ptr/Cargo.toml b/ptr/Cargo.toml new file mode 100644 index 0000000..1c5ae0c --- /dev/null +++ b/ptr/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "spaces_ptr" +version = "0.1.0" +edition = "2024" + +[dependencies] +bitcoin = { version = "0.32.2", features = ["base64", "serde"], default-features = false } +log = "0.4.14" +spaces_protocol = {path = "../protocol"} + +## optional features +bincode = { version = "2.0.1", features = [ "derive", "serde", "alloc" ], default-features = false, optional = true } +serde = { version = "^1.0", features = ["derive"], default-features = false, optional = true } +bech32 = { version = "0.11.0", optional = true } + +[dev-dependencies] +rand = "0.8.5" +serde_json = "1.0.132" + +[features] +default = ["std"] +serde = ["dep:serde"] +bincode = ["dep:bincode"] +bech32 = ["dep:bech32"] +std = ["serde", "bincode", "bech32"] + diff --git a/ptr/src/constants.rs b/ptr/src/constants.rs new file mode 100644 index 0000000..0a824f6 --- /dev/null +++ b/ptr/src/constants.rs @@ -0,0 +1,14 @@ +use bitcoin::Network; + +pub const PTR_MAINNET_HEIGHT : u32 = 922_777; +pub const PTR_TESTNET4_HEIGHT : u32 = 100_000; +pub const PTR_REGTEST_HEIGHT : u32 = 0; + +pub fn ptrs_start_height(network: &Network) -> u32 { + match network { + Network::Bitcoin => PTR_MAINNET_HEIGHT, + Network::Testnet => PTR_TESTNET4_HEIGHT, + Network::Regtest => PTR_REGTEST_HEIGHT, + _ => panic!("unsupported network {}", network) + } +} diff --git a/ptr/src/lib.rs b/ptr/src/lib.rs new file mode 100644 index 0000000..20c5f00 --- /dev/null +++ b/ptr/src/lib.rs @@ -0,0 +1,494 @@ +#[cfg(feature = "std")] +pub mod sptr; +pub mod constants; + +use std::collections::BTreeMap; +#[cfg(feature = "bincode")] +use bincode::{Decode, Encode}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid}; +use bitcoin::absolute::LockTime; +use bitcoin::opcodes::all::{OP_PUSHBYTES_33, OP_RETURN}; +use spaces_protocol::hasher::{KeyHasher, KeyHash, Hash}; +use spaces_protocol::slabel::SLabel; +use spaces_protocol::{SpaceOut}; +use crate::sptr::{Sptr}; + + +pub trait PtrSource { + fn get_ptr_outpoint(&mut self, sptr: &Sptr) -> spaces_protocol::errors::Result>; + + fn get_commitment(&mut self, key: &CommitmentKey) -> spaces_protocol::errors::Result>; + + fn get_commitments_tip(&mut self, key: &RegistryKey) -> spaces_protocol::errors::Result>; + + fn get_delegator(&mut self, sptr: &RegistrySptrKey) -> spaces_protocol::errors::Result>; + + fn get_ptrout(&mut self, outpoint: &OutPoint) -> spaces_protocol::errors::Result>; +} + +#[derive(Debug, Clone)] +pub struct Validator {} + +#[derive(Debug, Clone)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +/// A `TxChangeSet` captures all resulting state changes. +pub struct TxChangeSet { + #[cfg_attr(feature = "bincode", bincode(with_serde))] + pub txid: Txid, + /// List of transaction input indexes spending a ptrout. + pub spends: Vec, + /// List of transaction outputs creating a ptrout. + pub creates: Vec, + /// Any updates to existing ptrs mainly to remove a delegation + pub updates: Vec, + /// New commitments made + pub commitments: BTreeMap, + pub revoked_delegations: Vec, + pub new_delegations: Vec, +} + +#[derive(Clone, PartialEq, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +pub struct Delegation { + pub space: SLabel, + pub sptr_key: RegistrySptrKey, +} + +#[derive(Clone, PartialEq, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +pub struct FullPtrOut { + #[cfg_attr(feature = "bincode", bincode(with_serde))] + pub txid: Txid, + + #[cfg_attr(feature = "serde", serde(flatten))] + pub ptrout: PtrOut, +} + +/// PTR TxOut +/// This structure is a superset of [bitcoin::TxOut] +#[derive(Clone, PartialEq, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +pub struct PtrOut { + pub n: usize, + /// Any handle associated with this output + #[cfg_attr(feature = "serde", serde(flatten))] + pub sptr: Option, + /// The value of the output, in satoshis. + #[cfg_attr(feature = "bincode", bincode(with_serde))] + pub value: Amount, + /// The script which must be satisfied for the output to be spent. + #[cfg_attr(feature = "bincode", bincode(with_serde))] + pub script_pubkey: ScriptBuf, +} + +#[derive(Clone, PartialEq, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +pub struct Ptr { + pub genesis_spk: Vec, + pub data: Option>, +} + +#[derive(Clone, PartialEq, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +pub struct Commitment { + /// Merkle/Trie commitment to the current state. + pub state_root: [u8; 32], + + /// Previous state root (None for genesis). + pub prev_root: Option<[u8; 32]>, + + /// Running history hash + pub history_hash: [u8; 32], + + /// Block height at which the commitment was made + pub block_height: u32, +} + +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyKind { + Commitment = 0x01, + Sptr = 0x02, + Registry = 0x03, + RegistrySptr = 0x04, +} + +impl KeyKind { + #[inline] + pub fn as_byte(self) -> u8 { + self as u8 + } +} + +pub fn ns_hash(kind: KeyKind, data: [u8; 32]) -> [u8; 32] { + let mut buf = [0u8; 1 + 32]; + buf[0] = kind.as_byte(); + buf[1..].copy_from_slice(&data); + H::hash(&buf) +} + + +#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +pub struct RegistryKey([u8; 32]); + +#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Debug)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +pub struct RegistrySptrKey([u8; 32]); + +#[derive(Copy, Clone, Ord, PartialOrd, Eq, PartialEq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +pub struct CommitmentKey([u8; 32]); + +impl KeyHash for RegistryKey {} +impl KeyHash for RegistrySptrKey {} +impl KeyHash for CommitmentKey {} + +impl From for Hash { + fn from(value: RegistryKey) -> Self { + value.0 + } +} + +impl From for Hash { + fn from(value: RegistrySptrKey) -> Self { + value.0 + } +} + +impl From for Hash { + fn from(value: CommitmentKey) -> Self { + value.0 + } +} + +impl CommitmentKey { + pub fn new(space: &SLabel, root: [u8;32]) -> Self { + let mut data = [0u8;64]; + data[0..32].copy_from_slice(&H::hash(space.as_ref())); + data[32..64].copy_from_slice(&root); + Self(ns_hash::(KeyKind::Registry, H::hash(&data))) + } +} + + +impl RegistryKey { + pub fn from_slabel(space: &SLabel) -> Self { + Self(ns_hash::(KeyKind::Registry, H::hash(space.as_ref()))) + } +} + +impl RegistrySptrKey { + pub fn from_sptr(sptr: Sptr) -> Self { + RegistrySptrKey(ns_hash::(KeyKind::RegistrySptr, sptr.to_bytes())) + } +} + +impl Commitment { + pub fn key(&self) -> [u8; 32] { + ns_hash::(KeyKind::Commitment, self.state_root) + } +} + +#[derive(Clone)] +pub struct Stxo { + pub n: usize, + pub ptrout: PtrOut, + pub delegate: Option, +} + +#[derive(Clone)] +pub struct DelegateContext { + space: SLabel, + previous_commitment: Option, +} + +pub struct TxContext { + pub inputs: Vec, + pub relevant_sptr_spks: Vec +} + +impl TxContext { + pub fn spending_ptrs(src: &mut T, tx: &Transaction) -> spaces_protocol::errors::Result { + for input in tx.input.iter() { + if src.get_ptrout(&input.previous_output)?.is_some() { + return Ok(true); + } + } + Ok(false) + } + + /// Creates a [TxContext] from a Bitcoin [Transaction], loading all necessary data + /// for validation from the provided data source `src`. + /// + /// Returns `Some(TxContext)` if the transaction is ptrs tx. + /// Returns `None` if the transaction is not relevant. + pub fn from_tx( + src: &mut T, + tx: &Transaction, + has_spaces: bool, + ) -> spaces_protocol::errors::Result> { + let has_ptr_outputs = is_ptr_minting_locktime(&tx.lock_time) && + tx.output.iter().any(|out| out.is_ptr_output()); + let relevant = has_spaces || has_ptr_outputs || + Self::spending_ptrs(src, &tx)?; + + if !relevant { + return Ok(None); + } + + let mut inputs = Vec::with_capacity(tx.input.len()); + for (n, input) in tx.input.iter().enumerate() { + let ptrout = src.get_ptrout(&input.previous_output)?; + if let Some(ptrout) = ptrout { + let delegate = match &ptrout.sptr { + Some(sptr) => { + // TODO: how about just storing the sptr itself in sptr.spk? + let rsk = RegistrySptrKey::from_sptr::( + Sptr::from_spk::(ScriptBuf::from(sptr.genesis_spk.clone())) + ); + let slabel = src.get_delegator(&rsk)?; + if let Some(slabel) = slabel { + let registry_key = RegistryKey::from_slabel::(&slabel); + let state_root = src.get_commitments_tip(®istry_key)?; + let ck = match state_root { + Some(state_root) => { + let ck = CommitmentKey::new::(&slabel, state_root); + src.get_commitment(&ck)? + }, + None => None, + }; + + Some(DelegateContext { + space: slabel, + previous_commitment: ck, + }) + } else { + None + } + } + None => None, + }; + let ptrin = Stxo { + n, + ptrout, + delegate, + }; + inputs.push(ptrin); + } + } + + // for existence checks we need to find any previous sptrs from outputs + // TODO: technically we could fetch less by checking transfers + let mut ctx = TxContext { + inputs, + relevant_sptr_spks: Vec::with_capacity(tx.output.len()), + }; + for out in tx.output.iter() { + if !out.is_ptr_output() { + continue; + } + let sptr = Sptr::from_spk::(out.script_pubkey.clone()); + if src.get_ptr_outpoint(&sptr)?.is_some() { + ctx.relevant_sptr_spks.push(out.script_pubkey.clone()); + } + } + + Ok(Some(ctx)) + } +} + +pub fn transcript_hash(old: [u8; 32], new_root: [u8; 32]) -> [u8; 32] { + let mut data = [0u8; 64]; + data[0..32].copy_from_slice(&old); + data[32..64].copy_from_slice(&new_root); + H::hash(&data) +} + +impl Validator { + pub fn new() -> Self { + Self {} + } + + pub fn process( + &self, height: u32, + tx: &Transaction, + ctx: TxContext, + spent_space_utxos: Vec, + new_space_utxos: Vec, + ) -> TxChangeSet { + let mut changeset = TxChangeSet { + txid: tx.compute_txid(), + spends: vec![], + creates: vec![], + updates: vec![], + commitments: BTreeMap::new(), + revoked_delegations: vec![], + new_delegations: vec![], + }; + + let mut commitment_root = get_commitment_root(&tx); + + // Maintain space -> sptr registry mappings + for spent in spent_space_utxos { + let sptr = Sptr::from_spk::(spent.script_pubkey); + changeset.revoked_delegations.push(RegistrySptrKey::from_sptr::(sptr)); + } + for created in &new_space_utxos { + let space = match &created.space { + None => continue, + Some(space) => space + }; + let sptr = Sptr::from_spk::(created.script_pubkey.clone()); + changeset.new_delegations.push(Delegation { + space: space.name.clone(), + sptr_key: RegistrySptrKey::from_sptr::(sptr), + }) + } + + for input_ctx in ctx.inputs.into_iter() { + if let Some(delegate) = input_ctx.delegate { + if let Some(commitment_root) = commitment_root.take() { + let commitment = match delegate.previous_commitment { + None => Commitment { + state_root: commitment_root, + history_hash: commitment_root, + prev_root: None, + block_height: height, + }, + Some(prev) => { + Commitment { + state_root: commitment_root, + history_hash: transcript_hash::(prev.history_hash, commitment_root), + prev_root: Some(prev.state_root), + block_height: height, + } + } + }; + changeset.commitments.insert(delegate.space, commitment); + } + } + + changeset.spends.push(input_ctx.n); + self.process_spend( + tx, + input_ctx.n, + input_ctx.ptrout, + &mut changeset, + ); + } + + if !is_ptr_minting_locktime(&tx.lock_time) { + return changeset; + } + + for (n, output) in tx.output.iter().enumerate() { + let already_added = changeset.creates.iter().find(|x| x.n == n).is_some(); + let is_space = new_space_utxos.iter().find(|x| x.n == n).is_some(); + let is_ptr_out = output.is_ptr_output(); + if already_added || is_space || !is_ptr_out { + continue; + } + + let already_exists = ctx.relevant_sptr_spks.iter() + .find(|spk| output.script_pubkey.as_bytes() == spk.as_bytes()).is_some(); + if already_exists { + continue; + } + + changeset.creates.push(PtrOut { + n, + sptr: Some(Ptr { + genesis_spk: output.script_pubkey.to_bytes(), + data: None, + }), + value: output.value, + script_pubkey: output.script_pubkey.clone(), + }); + } + + changeset + } + + fn process_spend( + &self, + tx: &Transaction, + input_index: usize, + mut ptrout: PtrOut, + changeset: &mut TxChangeSet, + ) { + let ptr = match ptrout.sptr { + None => return, + Some(ptr) => ptr, + }; + let output_index = input_index + 1; + let output = tx.output.get(output_index); + + match output { + None => { + // TODO: No corresponding output found - could it be rebound? + return + } + Some(output) => { + ptrout.n = output_index; + ptrout.value = output.value; + ptrout.script_pubkey = output.script_pubkey.clone(); + ptrout.sptr = Some(ptr); + changeset.creates.push(ptrout); + } + } + } +} + +pub fn get_commitment_root(tx: &Transaction) -> Option<[u8; 32]> { + let txout = tx.output.first()?; + let script = txout.script_pubkey.to_bytes(); + + // Fixed length: 1 (OP_RETURN) + 1 (OP_PUSHBYTES_33) + 1 (marker) + 32 (data) + if script.len() != 35 { + return None; + } + + if script[0] != OP_RETURN.to_u8() || + script[1] != OP_PUSHBYTES_33.to_u8() || + script[2] != 0x77 /* Marker */ { + return None; + } + + let mut out = [0u8; 32]; + out.copy_from_slice(&script[3..]); + Some(out) +} + + + +pub fn is_ptr_minting_locktime(lock_time: &LockTime) -> bool { + if let LockTime::Seconds(s) = lock_time { + return s.to_consensus_u32() % 1000 == 777; + } + false +} + +pub trait PtrTrackableOutput { + fn is_ptr_output(&self) -> bool; +} + +impl PtrTrackableOutput for TxOut { + fn is_ptr_output(&self) -> bool { + self.value.to_sat() % 10 == 7 + } +} + diff --git a/ptr/src/sptr.rs b/ptr/src/sptr.rs new file mode 100644 index 0000000..1335d37 --- /dev/null +++ b/ptr/src/sptr.rs @@ -0,0 +1,129 @@ +use core::{fmt, str::FromStr}; +use bech32::{self, Hrp, Bech32m}; +use bitcoin::{ScriptBuf}; +use spaces_protocol::hasher::{Hash, KeyHash, KeyHasher}; +use crate::{ns_hash, KeyKind}; + +pub const SPTR_HRP: &str = "sptr"; + +impl KeyHash for Sptr {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Sptr(pub(crate) [u8; 32]); + +// COMMITMENT_KEY +// SPTR_KEY +// SPACE_KEY + +impl Sptr { + #[inline] + pub fn as_slice(&self) -> &[u8] { &self.0 } + #[inline] + pub fn to_bytes(self) -> [u8; 32] { self.0 } + + pub fn from_spk(spk: ScriptBuf) -> Self { + Self(ns_hash::(KeyKind::Sptr, H::hash(&spk.as_bytes()))) + } +} + +impl From for Hash { + fn from(value: Sptr) -> Self { + value.0 + } +} + +#[derive(Debug)] +pub enum SptrParseError { + Bech32(bech32::DecodeError), + InvalidHrp, + InvalidLen, +} + +impl fmt::Display for SptrParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SptrParseError::Bech32(e) => write!(f, "bech32 decode error: {e}"), + SptrParseError::InvalidHrp => f.write_str("invalid HRP for sptr"), + SptrParseError::InvalidLen => f.write_str("invalid data length; expected 32 bytes"), + } + } +} + +impl std::error::Error for SptrParseError {} + +#[cfg(feature = "serde")] +impl serde::Serialize for Sptr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Sptr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use core::str::FromStr; + let s = String::deserialize(deserializer)?; + Sptr::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl From for SptrParseError { + fn from(e: bech32::DecodeError) -> Self { SptrParseError::Bech32(e) } +} + +impl fmt::Display for Sptr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let hrp = Hrp::parse(SPTR_HRP).map_err(|_| fmt::Error)?; + let s = bech32::encode::(hrp, &self.0).map_err(|_| fmt::Error)?; + f.write_str(&s) + } +} + +impl FromStr for Sptr { + type Err = SptrParseError; + + fn from_str(s: &str) -> Result { + let (hrp, data) = bech32::decode(s)?; + if hrp.as_str() != SPTR_HRP { return Err(SptrParseError::InvalidHrp); } + if data.len() != 32 { return Err(SptrParseError::InvalidLen); } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&data); + Ok(Sptr(arr)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bech32::{Bech32m}; + + #[test] + fn sptr_roundtrip() { + let x = Sptr([7u8; 32]); + let s = x.to_string(); + let y: Sptr = s.parse().unwrap(); + assert_eq!(x, y); + } + + #[test] + fn rejects_wrong_hrp() { + let hrp = Hrp::parse("nope").unwrap(); + let s = bech32::encode::(hrp, &[0u8; 32]).unwrap(); + let err = s.parse::().unwrap_err(); + matches!(err, SptrParseError::InvalidHrp); + } + + #[test] + fn rejects_wrong_len() { + let hrp = Hrp::parse(SPTR_HRP).unwrap(); + let s = bech32::encode::(hrp, &[0u8; 31]).unwrap(); + let err = s.parse::().unwrap_err(); + matches!(err, SptrParseError::InvalidLen); + } +} diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 779239b..b40ea68 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -5,7 +5,9 @@ edition = "2021" [dependencies] spaces_protocol = { path = "../protocol", features = ["std"], version = "*" } +spaces_ptr = { path = "../ptr", features = ["std"]} bitcoin = { version = "0.32.2", features = ["base64", "serde"] } + # bdk version 1.0.0-beta.6 + hard coded patch for double spend fix from PR https://github.com/bitcoindevkit/bdk/pull/1765 bdk_wallet = { git = "https://github.com/buffrr/bdk.git", rev= "43bca8643dec6fdda99e4a29bf88709729af349e", features = ["keys-bip39", "rusqlite"] } secp256k1 = "0.29.0" diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index 5f94516..0797573 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -21,13 +21,15 @@ use bitcoin::{ Amount, FeeRate, Network, OutPoint, Psbt, Script, ScriptBuf, Sequence, Transaction, TxOut, Txid, Weight, Witness, }; +use bitcoin::address::NetworkUnchecked; use spaces_protocol::{ bitcoin::absolute::Height, constants::{BID_PSBT_INPUT_SEQUENCE, BID_PSBT_TX_VERSION}, script::SpaceScript, Covenant, FullSpaceOut, Space, }; - +use spaces_protocol::hasher::Hash; +use spaces_ptr::{FullPtrOut}; use crate::{ address::SpaceAddress, tx_event::TxRecord, DoubleUtxo, FullTxOut, SpaceScriptSigningInfo, SpacesWallet, @@ -83,6 +85,9 @@ pub enum StackRequest { Transfer(SpaceTransfer), Send(CoinTransfer), Execute(ExecuteRequest), + Ptr(PtrRequest), + PtrTransfer(PtrTransfer), + Commitment(CommitmentRequest), } pub enum StackOp { @@ -90,6 +95,7 @@ pub enum StackOp { Open(OpenRevealParams), Bid(BidRequest), Execute(ExecuteParams), + Ptr(PtrParams), } #[derive(Clone)] @@ -116,12 +122,29 @@ pub struct RegisterRequest { pub to: Option, } +#[derive(Debug, Clone)] +pub struct PtrRequest { + pub ptr: Address, +} + +#[derive(Debug, Clone)] +pub struct CommitmentRequest { + pub ptrout: FullPtrOut, + pub root: Hash, +} + #[derive(Debug, Clone)] pub struct SpaceTransfer { pub space: FullSpaceOut, pub recipient: SpaceAddress, } +#[derive(Debug, Clone)] +pub struct PtrTransfer { + pub ptr: FullPtrOut, + pub recipient: SpaceAddress, +} + #[derive(Debug, Clone)] pub struct CoinTransfer { pub amount: Amount, @@ -142,6 +165,12 @@ pub struct CreateParams { bidouts: Option, } +pub struct PtrParams { + transfers: Vec, + binds: Vec, + commitments: Vec, +} + #[derive(Clone, Debug)] pub struct OpenRequest { name: String, @@ -241,7 +270,7 @@ impl<'a, Cs: CoinSelectionAlgorithm> TxBuilderSpacesUtils<'a, Cs> for TxBuilder< placeholder.auction.outpoint.vout as u8, &offer, )?) - .expect("compressed psbt script bytes"); + .expect("compressed psbt script bytes"); let carrier = ScriptBuf::new_op_return(&compressed_psbt); @@ -352,7 +381,7 @@ impl Builder { Some(dust) => dust, }; let connector_dust = connector_dust(dust); - let magic_dust = magic_dust(dust); + let magic_dust = space_utxo_dust(dust); placeholder_outputs.push((addr1, connector_dust)); let addr2 = w.internal.next_unused_address(KeychainKind::External); @@ -362,7 +391,7 @@ impl Builder { let commit_psbt = { let mut builder = w.build_tx(unspendables, confirmed_only)?; - builder.nlocktime(magic_lock_time(median_time)); + builder.nlocktime(signal_space_utxo_tracking_lock_time(median_time)); // handle transfers if !space_transfers.is_empty() { @@ -394,7 +423,7 @@ impl Builder { None => tap_item.tweaked_address.minimal_non_dust().mul(2), Some(dust) => dust, }; - let magic_dust = magic_dust(dust); + let magic_dust = space_utxo_dust(dust); builder.add_recipient(tap_item.tweaked_address.clone(), magic_dust); tap_outputs.push(vout); @@ -642,6 +671,21 @@ impl Iterator for BuilderIterator<'_> { detailed })) } + StackOp::Ptr(params) => { + let tx = Builder::ptr_tx( + self.wallet, + self.median_time, + self.fee_rate, + self.unspendables.clone(), + self.confirmed_only, self.force, + params, + ); + Some(tx.map(|tx| { + let detailed = TxRecord::new(tx); + // TODO: add ptr metadata + detailed + })) + } } } } @@ -677,6 +721,12 @@ impl Builder { self } + pub fn add_commitment(mut self, req: CommitmentRequest) -> Self { + self.requests + .push(StackRequest::Commitment(req)); + self + } + pub fn add_open(mut self, name: &str, initial_amount: Amount) -> Self { self.requests.push(StackRequest::Open(OpenRequest { name: name.to_string(), @@ -696,6 +746,16 @@ impl Builder { self } + pub fn add_ptr_transfer(mut self, request: PtrTransfer) -> Self { + self.requests.push(StackRequest::PtrTransfer(request)); + self + } + + pub fn add_ptr(mut self, request: PtrRequest) -> Self { + self.requests.push(StackRequest::Ptr(request)); + self + } + pub fn add_send(mut self, request: CoinTransfer) -> Self { self.requests.push(StackRequest::Send(request)); self @@ -781,6 +841,9 @@ impl Builder { let mut transfers = Vec::new(); let mut sends = Vec::new(); let mut executes = Vec::new(); + let mut ptrs = Vec::new(); + let mut ptr_transfers = Vec::new(); + let mut commitments = Vec::new(); for req in self.requests { match req { StackRequest::Open(params) => opens.push(params), @@ -798,6 +861,9 @@ impl Builder { StackRequest::Send(send) => sends.push(send), StackRequest::Transfer(params) => transfers.push(params), StackRequest::Execute(params) => executes.push(params), + StackRequest::Ptr(params) => ptrs.push(params), + StackRequest::PtrTransfer(params) => ptr_transfers.push(params), + StackRequest::Commitment(req) => commitments.push(req), } } @@ -821,6 +887,16 @@ impl Builder { })); } + if !ptrs.is_empty() || !ptr_transfers.is_empty() || !commitments.is_empty() { + let params = PtrParams { + transfers: ptr_transfers, + binds: ptrs, + commitments, + }; + stack.push(StackOp::Ptr(params)) + } + + Ok(BuilderIterator { stack, dust, @@ -833,6 +909,85 @@ impl Builder { }) } + fn ptr_tx( + w: &mut SpacesWallet, + median_time: u64, + fee_rate: FeeRate, + unspendables: Vec, + confirmed_only: bool, + _force: bool, + params: PtrParams, + ) -> anyhow::Result { + let addr = w.next_unused_address(KeychainKind::Internal); + let mut builder = w.build_tx(unspendables, confirmed_only)?; + builder + .nlocktime(signal_ptr_tracking_lock_time(median_time)) + .fee_rate(fee_rate); + + // handle commitments + if !params.commitments.is_empty() { + if !params.transfers.is_empty() && params.binds.is_empty() { + return Err(anyhow!("combining commitments with binds and transfers is not yet supported")); + } + if params.commitments.len() != 1 { + return Err(anyhow!("multiple commitments are not yet supported")); + } + let commitment = params.commitments.first() + .expect("a commitment"); + + // First byte marker 0x77 || 32-byte root + let mut data = [0u8;33]; + data[0] = 0x77; + data[1..].copy_from_slice(&commitment.root); + // Add first output OP_RETURN commitment + builder.add_data(&data); + + let outpoint = OutPoint { + txid: commitment.ptrout.txid, + vout: commitment.ptrout.ptrout.n as _, + }; + // spend ptr + builder.add_utxo(outpoint) + .map_err(|e| anyhow!("could not spend sptr at {}:{}", outpoint, e))?; + // add replacement at n+1 + builder.add_recipient(commitment.ptrout.ptrout.script_pubkey.clone(), commitment.ptrout.ptrout.value); + + let psbt = builder.finish()?; + let signed = w.sign(psbt, None)?; + return Ok(signed); + } + + // ensure odd number of outputs to safely align transfers + if params.transfers.len() > 0 { + // TODO: avoid this once we have our own builder and can control change output + builder.add_recipient(addr.script_pubkey(), Amount::from_sat(2000)); + } + for transfer in params.transfers { + let outpoint = OutPoint { + txid: transfer.ptr.txid, + vout: transfer.ptr.ptrout.n as _, + }; + + // spend ptr + builder.add_utxo(outpoint) + .map_err(|e| anyhow!("could not transfer ptr at {}:{}", outpoint, e))?; + // add replacement at n+1 + builder.add_recipient(transfer.recipient.script_pubkey(), transfer.ptr.ptrout.value); + } + + // Add any binds last to not mess with input/output order for transfers + for ptr in params.binds { + builder.add_recipient( + ptr.ptr.assume_checked().script_pubkey(), + ptr_utxo_dust(Amount::from_sat(1000)), + ); + } + + let psbt = builder.finish()?; + let signed = w.sign(psbt, None)?; + Ok(signed) + } + fn bid_tx( w: &mut SpacesWallet, prev: FullSpaceOut, @@ -916,8 +1071,8 @@ impl Builder { let mut builder = w.build_tx(unspendables, confirmed_only)?; builder - // Added first to keep an odd number of outputs before adding transfers - .add_recipient(change_address, Amount::from_sat(1000)); + // TODO: avoid this! added first to keep an odd number of outputs before adding transfers + .add_recipient(change_address, Amount::from_sat(2000)); extra_prevouts.insert( params.reveal.commitment.outpoint, @@ -1016,9 +1171,9 @@ impl CoinSelectionAlgorithm for SpacesAwareCoinSelection { weighted_utxo.utxo.txout().value > SpacesAwareCoinSelection::DUST_THRESHOLD && !self - .exclude_outputs - .iter() - .any(|o| o == &weighted_utxo.utxo.outpoint()) + .exclude_outputs + .iter() + .any(|o| o == &weighted_utxo.utxo.outpoint()) }); let mut result = self.default_algorithm.coin_select( @@ -1049,13 +1204,25 @@ impl CoinSelectionAlgorithm for SpacesAwareCoinSelection { } } -pub fn magic_lock_time(median_time: u64) -> LockTime { +pub fn signal_ptr_tracking_lock_time(median_time: u64) -> LockTime { + let median_time = min(median_time, u32::MAX as u64) as u32; + let magic_time = median_time - (median_time % 1000) - (1000 - 777); + LockTime::from_time(magic_time).expect("valid time") +} + +pub fn ptr_utxo_dust(amount: Amount) -> Amount { + let amount = amount.to_sat(); + Amount::from_sat(amount - (amount % 10) + 7) +} + + +pub fn signal_space_utxo_tracking_lock_time(median_time: u64) -> LockTime { let median_time = min(median_time, u32::MAX as u64) as u32; let magic_time = median_time - (median_time % 1000) - (1000 - 222); LockTime::from_time(magic_time).expect("valid time") } -pub fn magic_dust(amount: Amount) -> Amount { +pub fn space_utxo_dust(amount: Amount) -> Amount { let amount = amount.to_sat(); Amount::from_sat(amount - (amount % 10) + 2) } diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 82ea6b2..1ac0858 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -42,7 +42,7 @@ use spaces_protocol::{ }, constants::{BID_PSBT_INPUT_SEQUENCE, BID_PSBT_TX_LOCK_TIME}, hasher::{KeyHasher, SpaceKey}, - prepare::{is_magic_lock_time, DataSource, TrackableOutput}, + prepare::{is_magic_lock_time, SpacesSource, TrackableOutput}, slabel::SLabel, Covenant, FullSpaceOut, Space, }; @@ -305,7 +305,7 @@ impl SpacesWallet { pub fn list_spaces_outpoints( &self, - src: &mut impl DataSource, + src: &mut impl SpacesSource, ) -> anyhow::Result> { let mut outs = Vec::new(); for unspent in self.list_unspent() { @@ -394,7 +394,7 @@ impl SpacesWallet { pub fn sign_event( &mut self, - src: &mut impl DataSource, + src: &mut impl SpacesSource, space: &str, mut event: NostrEvent, ) -> anyhow::Result { @@ -422,7 +422,7 @@ impl SpacesWallet { } pub fn verify_event( - src: &mut impl DataSource, + src: &mut impl SpacesSource, space: &str, mut event: NostrEvent, ) -> anyhow::Result { @@ -469,7 +469,7 @@ impl SpacesWallet { pub fn list_unspent_with_details( &mut self, - store: &mut impl DataSource, + store: &mut impl SpacesSource, ) -> anyhow::Result> { let mut wallet_outputs = Vec::new(); for output in self.internal.list_unspent() { @@ -495,7 +495,7 @@ impl SpacesWallet { &mut self, mem: impl Mempool, height: u32, - data_source: &mut impl DataSource, + data_source: &mut impl SpacesSource, ) -> anyhow::Result> { let unconfirmed_bids = self.unconfirmed_bids()?; let mut revert_txs = Vec::new(); @@ -743,7 +743,7 @@ impl SpacesWallet { pub fn buy( &mut self, - src: &mut impl DataSource, + src: &mut impl SpacesSource, listing: &Listing, fee_rate: FeeRate, ) -> anyhow::Result { @@ -796,7 +796,7 @@ impl SpacesWallet { } pub fn verify_listing( - src: &mut impl DataSource, + src: &mut impl SpacesSource, listing: &Listing, ) -> anyhow::Result<(SpaceAddress, FullSpaceOut)> { let label = SLabel::from_str(&listing.space)?; @@ -887,7 +887,7 @@ impl SpacesWallet { pub fn sell( &mut self, - src: &mut impl DataSource, + src: &mut impl SpacesSource, space: &str, asking_price: Amount, ) -> anyhow::Result { From de9bae656baa4974796c0d6389309842b669c386 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Fri, 10 Oct 2025 20:07:46 +0200 Subject: [PATCH 2/6] Fix space-cli & update docs --- SUBSPACES.md | 33 ++++++++++++++++++++++++++++++--- client/src/bin/space-cli.rs | 6 +++--- ptr/src/sptr.rs | 4 ---- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/SUBSPACES.md b/SUBSPACES.md index 9ea4871..59d52aa 100644 --- a/SUBSPACES.md +++ b/SUBSPACES.md @@ -14,14 +14,40 @@ $ space-cli operate @bitcoin Use [subs](https://github.com/spacesprotocol/subs) to issue subspaces off-chain and create commitments. + +An **end-user** can generate a key pair like this: + +``` +$ subs request alice@bitcoin +✔ Created handle request + → alice@bitcoin.req.json + → Private key saved: alice@bitcoin.priv +``` + +An **operator** such as @bitcoin, can accept requests into their tree: + +``` +$ subs add alice@bitcoin.req.json +``` + + +For this example, we will commit just one handle, but it's more efficient to add a large batch of handles before making a commitment. + +``` +$ subs commit +✔ Committed batch + → Tree root: 79d39952ac5a8d6daedd48e59c0a58d12d10644c09f2fa3c70e9fe76e72f866a +``` + + ### 3. Submit Commitments -Submit a commitment for your space with a Merkle root. Each commitment is cryptographically bound to all previous commitments. +After your tree is updated, commit it's root hash. Each commitment is cryptographically bound to all previous commitments you made on-chain. -**Example:** To submit a commitment for `@bitcoin` with root hash `85d3a410db41b317b7c0310df64cefb6504482c0b5c7e8a36c992ed0dfdb38af`: +**Example:** To submit a commitment for `@bitcoin` with root hash `79d39952ac5a8d6daedd48e59c0a58d12d10644c09f2fa3c70e9fe76e72f866a`: ```bash -$ space-cli commit @bitcoin 85d3a410db41b317b7c0310df64cefb6504482c0b5c7e8a36c992ed0dfdb38af +$ space-cli commit @bitcoin 79d39952ac5a8d6daedd48e59c0a58d12d10644c09f2fa3c70e9fe76e72f866a ``` **Retrieve commitments** for a space: @@ -30,6 +56,7 @@ $ space-cli commit @bitcoin 85d3a410db41b317b7c0310df64cefb6504482c0b5c7e8a36c99 $ space-cli getcommitment @bitcoin ``` + ### Delegating Operational Control You can authorize another party to make commitments on your behalf: diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index de00f41..648b4b4 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -1144,18 +1144,18 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client false, ) .await?; - + let addr = SpaceAddress::from_str(&sptr_addr).expect("valid"); println!("Creating UTXO for sptr {}", sptr); cli.send_request( Some(RpcWalletRequest::CreatePtr(CreatePtrParams { - address: sptr_addr, + address: addr.0.to_string(), })), None, fee_rate, false, ) .await?; - println!("Space should be operational was txs are confirmed"); + println!("Space should be operational once txs are confirmed"); } Commands::Commit { space, root, fee_rate } => { let space_info = match cli.client.get_space(&space).await? { diff --git a/ptr/src/sptr.rs b/ptr/src/sptr.rs index 1335d37..1bcc2dd 100644 --- a/ptr/src/sptr.rs +++ b/ptr/src/sptr.rs @@ -11,10 +11,6 @@ impl KeyHash for Sptr {} #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Sptr(pub(crate) [u8; 32]); -// COMMITMENT_KEY -// SPTR_KEY -// SPACE_KEY - impl Sptr { #[inline] pub fn as_slice(&self) -> &[u8] { &self.0 } From 25f682ff3da7b264e52745ca8083c98fd957b4ae Mon Sep 17 00:00:00 2001 From: Buffrr Date: Fri, 10 Oct 2025 22:14:39 +0200 Subject: [PATCH 3/6] Update ptrs testnet4 height --- client/src/client.rs | 16 +++++++++--- client/src/config.rs | 2 ++ client/src/spaces.rs | 8 ++++++ client/src/store/chain.rs | 53 +++++++++++++++++++++------------------ protocol/src/constants.rs | 24 ++++++++++++++++++ ptr/src/constants.rs | 2 +- 6 files changed, 76 insertions(+), 29 deletions(-) diff --git a/client/src/client.rs b/client/src/client.rs index c5a5dad..6f921dc 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -161,7 +161,7 @@ impl Client { } } // Ptrs tip must connect to block - { + if chain.can_scan_ptrs(height) { let tip = chain.ptrs_tip(); if tip.hash != block.header.prev_blockhash || tip.height + 1 != height { return Err(SyncError { @@ -254,8 +254,14 @@ impl Client { self.apply_space_tx(chain, &tx, validated_tx); } - let ptrs_ctx = - { spaces_ptr::TxContext::from_tx::(chain, tx, spaceouts.is_some() || spaceouts_input_ctx.is_some())? }; + let ptrs_ctx = if chain.can_scan_ptrs(height) { + spaces_ptr::TxContext::from_tx::( + chain, + tx, + spaceouts.is_some() || spaceouts_input_ctx.is_some())? + } else { + None + }; if let Some(ptrs_ctx) = ptrs_ctx { let spent_spaceouts = spaceouts_input_ctx.unwrap_or_default().into_iter() @@ -286,7 +292,9 @@ impl Client { } chain.update_spaces_tip(height, block_hash); - chain.update_ptrs_tip(height, block_hash); + if chain.can_scan_ptrs(height) { + chain.update_ptrs_tip(height, block_hash); + } Ok((spaces_meta, ptr_meta)) } diff --git a/client/src/config.rs b/client/src/config.rs index 6685158..6fd1fc4 100644 --- a/client/src/config.rs +++ b/client/src/config.rs @@ -195,10 +195,12 @@ impl Args { ); let genesis = Spaced::genesis(args.chain); + let ptr_genesis = Spaced::ptr_genesis(args.chain); let chain = Chain::load( args.chain.fallback_network(), genesis, + ptr_genesis, &data_dir, args.block_index || args.block_index_full, args.block_index || args.block_index_full, // TODO: option to index ptrs diff --git a/client/src/spaces.rs b/client/src/spaces.rs index e7d1743..17a90fb 100644 --- a/client/src/spaces.rs +++ b/client/src/spaces.rs @@ -172,4 +172,12 @@ impl Spaced { _ => panic!("unsupported network"), } } + + pub fn ptr_genesis(network: ExtendedNetwork) -> ChainAnchor { + match network { + ExtendedNetwork::Testnet4 => ChainAnchor::PTR_TESTNET4(), + ExtendedNetwork::Regtest => ChainAnchor::PTR_TESTNET4(), + _ => panic!("unsupported network"), + } + } } diff --git a/client/src/store/chain.rs b/client/src/store/chain.rs index 1b278d3..48aa8f7 100644 --- a/client/src/store/chain.rs +++ b/client/src/store/chain.rs @@ -39,6 +39,7 @@ const_assert!( pub struct Chain { db: LiveStore, idx: LiveIndex, + ptrs_genesis: ChainAnchor, } #[derive(Clone)] @@ -94,8 +95,7 @@ impl Chain { self.db.pt.state.get_ptr_info(key) } - pub fn load(network: Network, genesis: ChainAnchor, dir: &Path, index_spaces: bool, index_ptrs: bool) -> anyhow::Result { - let ptrs_genesis = spaces_ptr::constants::ptrs_start_height(&network); + 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"); let initial_sp_sync = !proto_db_path.exists(); @@ -109,7 +109,7 @@ impl Chain { let pt_store = PtrStore::open(ptrs_db_path)?; let pt = PtrLiveStore { - state: pt_store.begin(&genesis)?, + state: pt_store.begin(&ptrs_genesis)?, store: pt_store, }; @@ -130,27 +130,23 @@ impl Chain { let chain = Chain { db: LiveStore { sp, pt }, idx: LiveIndex { sp: sp_idx, pt: pt_idx }, + ptrs_genesis }; // If spaces synced past the ptrs point, reset the tip if initial_pt_sync { let sp_tip = chain.db.sp.state.tip.read().expect("tip").clone(); - if sp_tip.height > ptrs_genesis { + if sp_tip.height > ptrs_genesis.height { info!("spaces tip = {} > ptrs genesis = {} - rescanning to index ptrs", - sp_tip.height, ptrs_genesis + sp_tip.height, ptrs_genesis.height ); assert_eq!( - ptrs_genesis % COMMIT_BLOCK_INTERVAL, 0, + ptrs_genesis.height % COMMIT_BLOCK_INTERVAL, 0, "ptrs genesis must align with commit interval" ); - chain.restore_spaces(|height| { - if height != ptrs_genesis { - // return a dummy hash until we have a checkpoint matching ptrs genesis - return Ok(BlockHash::from_slice(&[0u8; 32]).expect("hash")); - } - - Ok(sp_tip.hash) - })?; + chain.restore_spaces(|_| { + return Ok(BlockHash::from_slice(&[0u8; 32]).expect("hash")); + }, Some(ptrs_genesis.height))?; } } @@ -263,6 +259,10 @@ impl Chain { *self.db.pt.state.tip.read().expect("ptrs tip") } + pub fn can_scan_ptrs(&self, height: u32) -> bool { + height > self.ptrs_genesis.height + } + pub fn update_ptrs_tip(&self, height: u32, block_hash: BlockHash) { let mut tip = self.db.pt.state.tip.write().expect("write tip"); tip.height = height; @@ -349,7 +349,7 @@ impl Chain { where F: Fn(u32) -> anyhow::Result, { - let point = self.restore_spaces(get_block_hash)?; + let point = self.restore_spaces(get_block_hash, None)?; self.restore_ptrs(point) } @@ -405,7 +405,7 @@ impl Chain { Ok(()) } - pub fn restore_spaces(&self, get_block_hash: F) -> anyhow::Result + pub fn restore_spaces(&self, get_block_hash: F, restore_to_height: Option) -> anyhow::Result where F: Fn(u32) -> anyhow::Result, { @@ -413,14 +413,19 @@ impl Chain { for (snapshot_index, snapshot) in chain_iter.enumerate() { let chain_snapshot = snapshot?; let chain_checkpoint: ChainAnchor = chain_snapshot.metadata().try_into()?; - let required_hash = get_block_hash(chain_checkpoint.height)?; - - if required_hash != chain_checkpoint.hash { - info!( - "Could not restore to block={} height={}", - chain_checkpoint.hash, chain_checkpoint.height - ); - continue; + if let Some(restore_to_height) = restore_to_height { + if restore_to_height != chain_checkpoint.height { + continue; + } + } else { + let required_hash = get_block_hash(chain_checkpoint.height)?; + if required_hash != chain_checkpoint.hash { + info!( + "Could not restore to block={} height={}", + chain_checkpoint.hash, chain_checkpoint.height + ); + continue; + } } info!( diff --git a/protocol/src/constants.rs b/protocol/src/constants.rs index 1e596ed..10fa8cc 100644 --- a/protocol/src/constants.rs +++ b/protocol/src/constants.rs @@ -84,6 +84,18 @@ impl ChainAnchor { ) }; + pub const PTR_TESTNET4: fn() -> Self = || { + Self::new( + [ + 0x94, 0x94, 0xe5, 0x15, 0x75, 0xaa, 0xcf, 0x09, + 0x45, 0xc1, 0x7a, 0x30, 0xf3, 0x53, 0x20, 0xe8, + 0x1d, 0x2b, 0xd0, 0xed, 0x6a, 0xaa, 0xb3, 0xc3, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ], + 100_008, + ) + }; + // Testnet activation block pub const TESTNET: fn() -> Self = || { Self::new( @@ -109,6 +121,18 @@ impl ChainAnchor { 0, ) }; + + pub const PTR_REGTEST: fn() -> Self = || { + Self::new( + [ + 0x06, 0x22, 0x6e, 0x46, 0x11, 0x1a, 0x0b, 0x59, + 0xca, 0xaf, 0x12, 0x60, 0x43, 0xeb, 0x5b, 0xbf, + 0x28, 0xc3, 0x4f, 0x3a, 0x5e, 0x33, 0x2a, 0x1f, + 0xc7, 0xb2, 0xb7, 0x3c, 0xf1, 0x88, 0x91, 0x0f, + ], + 0, + ) + }; } #[cfg(feature = "bincode")] diff --git a/ptr/src/constants.rs b/ptr/src/constants.rs index 0a824f6..fd9fc0e 100644 --- a/ptr/src/constants.rs +++ b/ptr/src/constants.rs @@ -1,7 +1,7 @@ use bitcoin::Network; pub const PTR_MAINNET_HEIGHT : u32 = 922_777; -pub const PTR_TESTNET4_HEIGHT : u32 = 100_000; +pub const PTR_TESTNET4_HEIGHT : u32 = 100_008; pub const PTR_REGTEST_HEIGHT : u32 = 0; pub fn ptrs_start_height(network: &Network) -> u32 { From 49ae6026eae79751187c6cc1550afaa029180a57 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Fri, 10 Oct 2025 22:23:28 +0200 Subject: [PATCH 4/6] Fix ptrs index genesis block --- client/src/store/chain.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/store/chain.rs b/client/src/store/chain.rs index 48aa8f7..92996f0 100644 --- a/client/src/store/chain.rs +++ b/client/src/store/chain.rs @@ -124,7 +124,7 @@ impl Chain { let mut pt_idx = None; if index_ptrs { let current_tip = pt.state.tip.read().expect("tip"); - pt_idx = Some(load_pt_index(dir, genesis, *current_tip, initial_pt_sync)?) + pt_idx = Some(load_pt_index(dir, ptrs_genesis, *current_tip, initial_pt_sync)?) } let chain = Chain { From 3b292faa81c84e113b25482a244ca0a43d989044 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Fri, 10 Oct 2025 22:57:16 +0200 Subject: [PATCH 5/6] Use spk directly for create ptrs and other fixes --- client/src/bin/space-cli.rs | 24 ++++++++++-------------- client/src/client.rs | 1 + client/src/rpc.rs | 2 +- client/src/spaces.rs | 2 +- client/src/wallets.rs | 10 ++++++---- client/tests/ptr_tests.rs | 19 ++++++++----------- wallet/src/builder.rs | 5 ++--- 7 files changed, 29 insertions(+), 34 deletions(-) diff --git a/client/src/bin/space-cli.rs b/client/src/bin/space-cli.rs index 648b4b4..085f322 100644 --- a/client/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -38,10 +38,9 @@ use spaces_client::{ use spaces_client::rpc::{CommitParams, CreatePtrParams, TransferPtrParams}; use spaces_client::store::Sha256; use spaces_protocol::bitcoin::{Amount, FeeRate, OutPoint, Txid}; -use spaces_protocol::bitcoin::params::Params; use spaces_protocol::slabel::SLabel; use spaces_ptr::sptr::Sptr; -use spaces_wallet::{bitcoin, bitcoin::secp256k1::schnorr::Signature, export::WalletExport, nostr::{NostrEvent, NostrTag}, Listing}; +use spaces_wallet::{bitcoin::secp256k1::schnorr::Signature, export::WalletExport, nostr::{NostrEvent, NostrTag}, Listing}; use spaces_wallet::address::SpaceAddress; use spaces_wallet::bitcoin::hashes::sha256; use spaces_wallet::bitcoin::ScriptBuf; @@ -1062,15 +1061,11 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client let spk = ScriptBuf::from(hex::decode(spk) .map_err(|_| ClientError::Custom("Invalid spk hex".to_string()))?); - let addr = bitcoin::address::Address::from_script(spk.as_script(), - Params::new(cli.network.fallback_network())) - .map_err(|_| ClientError::Custom("Invalid spk".to_string()))?; - - let sptr = Sptr::from_spk::(spk); + let sptr = Sptr::from_spk::(spk.clone()); println!("Creating sptr: {}", sptr); cli.send_request( Some(RpcWalletRequest::CreatePtr(CreatePtrParams { - address: addr.to_string(), + spk: hex::encode(spk.as_bytes()), })), None, fee_rate, @@ -1129,26 +1124,27 @@ async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), Client return Err(ClientError::Custom("space is already operational".to_string())); } - let sptr_addr = cli.client.wallet_get_new_address(&cli.wallet, AddressKind::Space).await?; - let sptr = Sptr::from_spk::(SpaceAddress::from_str(&sptr_addr) - .expect("valid").script_pubkey()); + let address = cli.client.wallet_get_new_address(&cli.wallet, AddressKind::Space).await?; + let address = SpaceAddress::from_str(&address) + .expect("valid"); + let spk = address.script_pubkey(); + let sptr = Sptr::from_spk::(spk.clone()); println!("Assigning space to sptr {}", sptr); cli.send_request( Some(RpcWalletRequest::Transfer(TransferSpacesParams { spaces: vec![space], - to: Some(sptr_addr.clone()), + to: Some(address.to_string()), })), None, fee_rate, false, ) .await?; - let addr = SpaceAddress::from_str(&sptr_addr).expect("valid"); println!("Creating UTXO for sptr {}", sptr); cli.send_request( Some(RpcWalletRequest::CreatePtr(CreatePtrParams { - address: addr.0.to_string(), + spk: hex::encode(spk.as_bytes()), })), None, fee_rate, diff --git a/client/src/client.rs b/client/src/client.rs index 6f921dc..a7f1653 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -286,6 +286,7 @@ impl Client { }, }); } + self.apply_ptrs_tx(chain, tx, ptrs_validated); } } diff --git a/client/src/rpc.rs b/client/src/rpc.rs index 1dca23a..3fac1bf 100644 --- a/client/src/rpc.rs +++ b/client/src/rpc.rs @@ -448,7 +448,7 @@ pub struct TransferPtrParams { #[derive(Clone, Serialize, Deserialize)] pub struct CreatePtrParams { - pub address: String, + pub spk: String, } #[derive(Clone, Serialize, Deserialize)] diff --git a/client/src/spaces.rs b/client/src/spaces.rs index 17a90fb..eccf441 100644 --- a/client/src/spaces.rs +++ b/client/src/spaces.rs @@ -176,7 +176,7 @@ impl Spaced { pub fn ptr_genesis(network: ExtendedNetwork) -> ChainAnchor { match network { ExtendedNetwork::Testnet4 => ChainAnchor::PTR_TESTNET4(), - ExtendedNetwork::Regtest => ChainAnchor::PTR_TESTNET4(), + ExtendedNetwork::Regtest => ChainAnchor::PTR_REGTEST(), _ => panic!("unsupported network"), } } diff --git a/client/src/wallets.rs b/client/src/wallets.rs index a9940ab..9bc77c4 100644 --- a/client/src/wallets.rs +++ b/client/src/wallets.rs @@ -36,7 +36,7 @@ use tokio::{ time::Instant, }; use spaces_protocol::bitcoin::address::ParseError; -use spaces_protocol::bitcoin::Network; +use spaces_protocol::bitcoin::{Network, ScriptBuf}; use spaces_ptr::sptr::{Sptr, SptrParseError, SPTR_HRP}; use spaces_wallet::builder::{CommitmentRequest, PtrRequest, PtrTransfer}; use crate::{calc_progress, checker::TxChecker, client::BlockSource, config::ExtendedNetwork, rpc::{RpcWalletRequest, RpcWalletTxBuilder, WalletLoadRequest}, source::{ @@ -1275,10 +1275,12 @@ impl RpcWallet { } } RpcWalletRequest::CreatePtr(params) => { - let addr = Address::from_str(¶ms.address) - .map_err(|e| anyhow!("transferptr: invalid address for ptr {}: {:?}", params.address, e))?; + let spk_raw = hex::decode(params.spk) + .map_err(|e| anyhow!("transferptr: invalid spk: {:?}", e))?; + + let spk = ScriptBuf::from(spk_raw); builder = builder.add_ptr(PtrRequest { - ptr: addr, + spk, }) } RpcWalletRequest::Commit(params) => { diff --git a/client/tests/ptr_tests.rs b/client/tests/ptr_tests.rs index a5eec3c..5e99da9 100644 --- a/client/tests/ptr_tests.rs +++ b/client/tests/ptr_tests.rs @@ -16,24 +16,25 @@ use spaces_ptr::transcript_hash; use spaces_testutil::TestRig; use spaces_wallet::{export::WalletExport}; use spaces_wallet::address::SpaceAddress; -use spaces_wallet::bitcoin::{Network, ScriptBuf}; const ALICE: &str = "wallet_99"; const BOB: &str = "wallet_98"; const EVE: &str = "wallet_93"; async fn it_should_create_sptrs(rig: &TestRig) -> anyhow::Result<()> { - - println!("sptr example: {}", Sptr::from_spk::(ScriptBuf::from(hex::decode("5120d3c3196cb3ed7fa79c882ed62f8e5942e546130d5ae5983da67dbb6c9bdd2e79").expect("valid")))); - rig.wait_until_wallet_synced(ALICE).await?; // 1) Create ptr bound to addr0 (spk0) let addr0 = rig.spaced.client.wallet_get_new_address(ALICE, AddressKind::Coin).await?; + let addr0_spk = bitcoin::address::Address::from_str(&addr0) + .expect("valid").assume_checked() + .script_pubkey(); + let addr0_spk_string = hex::encode(addr0_spk.as_bytes()); + let create0 = wallet_do( rig, ALICE, - vec![RpcWalletRequest::CreatePtr(CreatePtrParams { address: addr0.clone() })], + vec![RpcWalletRequest::CreatePtr(CreatePtrParams { spk: addr0_spk_string.clone() })], false, ).await.expect("CreatePtr addr0"); assert!(wallet_res_err(&create0).is_ok(), "CreatePtr(addr0) must not error"); @@ -86,7 +87,7 @@ async fn it_should_create_sptrs(rig: &TestRig) -> anyhow::Result<()> { let dup = wallet_do( rig, ALICE, - vec![RpcWalletRequest::CreatePtr(CreatePtrParams { address: addr0.clone() })], + vec![RpcWalletRequest::CreatePtr(CreatePtrParams { spk: addr0_spk_string })], false, ).await.expect("duplicate CreatePtr(addr0)"); assert!(wallet_res_err(&dup).is_ok(), "duplicate CreatePtr should not error"); @@ -171,15 +172,11 @@ async fn it_should_operate_space(rig: &TestRig) -> anyhow::Result<()> { assert_eq!(current_spk, spk_after, "space spk must remain the same after renewal"); // --- Create/bind an SPTR using the SAME scriptPubKey as the space --- - let space_addr = bitcoin::address::Address::from_script(¤t_spk, Network::Regtest) - .expect("derive address from space spk") - .to_string(); - let create_ptr_res = wallet_do( rig, ALICE, vec![RpcWalletRequest::CreatePtr(CreatePtrParams { - address: space_addr.clone(), + spk: hex::encode(current_spk.as_bytes()), })], false, ) diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index 0797573..3205ed4 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -21,7 +21,6 @@ use bitcoin::{ Amount, FeeRate, Network, OutPoint, Psbt, Script, ScriptBuf, Sequence, Transaction, TxOut, Txid, Weight, Witness, }; -use bitcoin::address::NetworkUnchecked; use spaces_protocol::{ bitcoin::absolute::Height, constants::{BID_PSBT_INPUT_SEQUENCE, BID_PSBT_TX_VERSION}, @@ -124,7 +123,7 @@ pub struct RegisterRequest { #[derive(Debug, Clone)] pub struct PtrRequest { - pub ptr: Address, + pub spk: ScriptBuf, } #[derive(Debug, Clone)] @@ -978,7 +977,7 @@ impl Builder { // Add any binds last to not mess with input/output order for transfers for ptr in params.binds { builder.add_recipient( - ptr.ptr.assume_checked().script_pubkey(), + ptr.spk, ptr_utxo_dust(Amount::from_sat(1000)), ); } From 37d2d6db61e76d84b590fe18ce1f8386711eba59 Mon Sep 17 00:00:00 2001 From: Buffrr Date: Fri, 10 Oct 2025 23:11:59 +0200 Subject: [PATCH 6/6] Fix ptr indexing --- client/src/client.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/src/client.rs b/client/src/client.rs index a7f1653..fae5546 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -286,9 +286,8 @@ impl Client { }, }); } - - self.apply_ptrs_tx(chain, tx, ptrs_validated); } + self.apply_ptrs_tx(chain, tx, ptrs_validated); } }