diff --git a/.gitignore b/.gitignore index f49ba51..512bd55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target .idea -.DS_store \ No newline at end of file +.DS_store +.zone diff --git a/Cargo.lock b/Cargo.lock index 572a243..87d3cb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,6 +412,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.11.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd016a0ddc7cb13661bf5576073ce07330a693f8608a1320b4e20561cc12cdc" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.10.0" @@ -496,7 +505,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", ] @@ -555,6 +564,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "const-oid" +version = "0.10.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ff6be19477a1bd5441f382916a89bc2a0b2c35db6d41e0f6e8538bf6d6463f" + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -611,6 +626,17 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.0-rc.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b8ce8218c97789f16356e7896b3714f26c2ee1079b79c0b7ae7064bb9089fa" +dependencies = [ + "getrandom", + "hybrid-array", + "rand_core", +] + [[package]] name = "ctrlc" version = "3.4.4" @@ -652,10 +678,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.0-pre.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2e3d6615d99707295a9673e889bf363a04b2a466bd320c65a72536f7577379" +dependencies = [ + "block-buffer 0.11.0-rc.3", + "const-oid", + "crypto-common 0.2.0-rc.1", +] + [[package]] name = "directories" version = "5.0.1" @@ -683,6 +720,18 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "domain" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64008666d9f3b6a88a63cd28ad8f3a5a859b8037e11bfb680c1b24945ea1c28d" +dependencies = [ + "bytes", + "octseq", + "serde", + "time", +] + [[package]] name = "either" version = "1.13.0" @@ -701,9 +750,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b35839ba51819680ba087cd351788c9a3c476841207e0b8cee0b04722343b9" +checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0" dependencies = [ "anstream", "anstyle", @@ -893,13 +942,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.12" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -908,6 +959,18 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "h2" version = "0.3.25" @@ -1077,6 +1140,15 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.28" @@ -1232,10 +1304,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -1526,6 +1599,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "octseq" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126c3ca37c9c44cec575247f43a3e4374d8927684f129d2beeb0d2cef262fe12" +dependencies = [ + "bytes", + "serde", +] + [[package]] name = "once_cell" version = "1.19.0" @@ -1733,18 +1816,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "protocol" -version = "0.0.6" -dependencies = [ - "bincode", - "bitcoin", - "log", - "rand", - "serde", - "serde_json", -] - [[package]] name = "quinn" version = "0.11.5" @@ -2083,6 +2154,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" + [[package]] name = "ryu" version = "1.0.17" @@ -2237,8 +2314,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.6" -source = "git+https://github.com/risc0/RustCrypto-hashes?tag=sha2-v0.10.6-risczero.0#7fd6900c4f637bd15ee2642dfa77110f8f1ad065" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", @@ -2247,13 +2325,13 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.11.0-pre.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "540c0893cce56cdbcfebcec191ec8e0f470dd1889b6e7a0b503e310a94a168f5" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", + "digest 0.11.0-pre.9", ] [[package]] @@ -2307,8 +2385,19 @@ dependencies = [ ] [[package]] -name = "spaced" -version = "0.0.6" +name = "spacedb" +version = "0.0.4" +source = "git+https://github.com/spacesprotocol/spacedb?rev=e07259ac6fc9cea1b06ee2cfdbb93d8c3039658a#e07259ac6fc9cea1b06ee2cfdbb93d8c3039658a" +dependencies = [ + "bincode", + "hex", + "libc", + "sha2 0.11.0-pre.4", +] + +[[package]] +name = "spaces_client" +version = "0.0.7" dependencies = [ "anyhow", "assert_cmd", @@ -2318,34 +2407,86 @@ dependencies = [ "colored", "ctrlc", "directories", + "domain", "env_logger", "futures", "hex", "jsonrpsee", "log", "predicates", - "protocol", "reqwest", "serde", "serde_json", "spacedb", + "spaces_protocol", + "spaces_testutil", + "spaces_wallet", "tabled", - "testutil", "threadpool", "tokio", "toml", - "wallet", + "tower", ] [[package]] -name = "spacedb" -version = "0.0.2" -source = "git+https://github.com/spacesprotocol/spacedb?tag=0.0.2#74eec1903552eef475fc73beb66c8bce9b2cbd06" +name = "spaces_protocol" +version = "0.0.7" +dependencies = [ + "bincode", + "bitcoin", + "log", + "rand", + "serde", + "serde_json", +] + +[[package]] +name = "spaces_testutil" +version = "0.0.1" +dependencies = [ + "anyhow", + "assert_cmd", + "bitcoind", + "spaces_client", + "zip", +] + +[[package]] +name = "spaces_veritas" +version = "0.0.7" dependencies = [ + "base64 0.22.1", "bincode", + "env_logger", + "getrandom", + "gloo-timers", "hex", - "libc", - "sha2 0.10.6", + "js-sys", + "log", + "ring", + "spacedb", + "spaces_protocol", + "wasm-bindgen", +] + +[[package]] +name = "spaces_wallet" +version = "0.0.7" +dependencies = [ + "anyhow", + "bdk_wallet", + "bech32", + "bincode", + "bitcoin", + "ctrlc", + "hex", + "jsonrpc", + "log", + "secp256k1", + "serde", + "serde_json", + "spaces_protocol", + "tempfile", ] [[package]] @@ -2447,17 +2588,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" -[[package]] -name = "testutil" -version = "0.0.1" -dependencies = [ - "anyhow", - "assert_cmd", - "bitcoind", - "spaced", - "zip", -] - [[package]] name = "thiserror" version = "1.0.63" @@ -2792,26 +2922,6 @@ dependencies = [ "libc", ] -[[package]] -name = "wallet" -version = "0.0.6" -dependencies = [ - "anyhow", - "bdk_wallet", - "bech32", - "bincode", - "bitcoin", - "ctrlc", - "hex", - "jsonrpc", - "log", - "protocol", - "secp256k1", - "serde", - "serde_json", - "tempfile", -] - [[package]] name = "want" version = "0.3.1" @@ -2829,23 +2939,24 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", + "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", - "once_cell", "proc-macro2", "quote", "syn 2.0.52", @@ -2866,9 +2977,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2876,9 +2987,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -2889,9 +3000,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] [[package]] name = "web-sys" diff --git a/Cargo.toml b/Cargo.toml index 75f84c9..fc35b08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] resolver = "2" -members = [ "node", "protocol", "testutil", "wallet"] \ No newline at end of file +members = [ "client", "protocol", "veritas", "testutil", "wallet"] diff --git a/README.md b/README.md index e45b83c..816cede 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Bitcoin Core of version 28+ is required. It can be installed from the official [ ```sh git clone https://github.com/spacesprotocol/spaced && cd spaced -cargo install --path node --locked +cargo install --path client --locked ``` Make sure it's in your path @@ -66,12 +66,12 @@ spaced --chain testnet4 --bitcoin-rpc-user testnet4 --bitcoin-rpc-password testn ## Project Structure -| Package | Requires std | Description | -|----------|------------------|------------------------------------------------| -| node | Yes | Daemon and wallet service | -| wallet | Yes (no-std WIP) | wallet library for building spaces transactions| -| protocol | No | Protocol consensus library | - +| Package | Requires std | Description | +|----------|-----------------|-------------------------------------------------------------------------------------------------| +| client | Yes | Bitcoin consensus client and wallet service | +| wallet | Yes (no-std WIP) | Wallet library for building spaces transactions | +| protocol | No | Protocol consensus library | +| veritas | No | Stateless verifier library for mobile and other resource constrained devices with wasm support. | ## License diff --git a/node/Cargo.toml b/client/Cargo.toml similarity index 68% rename from node/Cargo.toml rename to client/Cargo.toml index 54f3783..6a4d731 100644 --- a/node/Cargo.toml +++ b/client/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "spaced" -version = "0.0.6" +name = "spaces_client" +version = "0.0.7" edition = "2021" @@ -16,7 +16,10 @@ path = "src/bin/spaced.rs" path = "src/lib.rs" [dependencies] -wallet = { path = "../wallet" } +spaces_wallet = { path = "../wallet" } +spaces_protocol = { path = "../protocol", version = "*", features = ["std"]} +spacedb = { git = "https://github.com/spacesprotocol/spacedb", rev = "e07259ac6fc9cea1b06ee2cfdbb93d8c3039658a" } + tokio = { version = "1.37.0", features = ["signal"] } ctrlc = "3.4.4" anyhow = "1.0.86" @@ -30,15 +33,16 @@ directories = "5.0.1" env_logger = "0.11.3" serde_json = "1.0.116" bincode = {version = "2.0.0-rc.3", features = ["serde", "derive"]} -protocol = { path = "../protocol", version = "*", features = ["std"]} -spacedb = { git = "https://github.com/spacesprotocol/spacedb", tag = "0.0.2" } base64 = "0.22.1" futures = "0.3.30" reqwest = { version = "0.12.5", default-features = false, features = ["json", "blocking", "rustls-tls"] } threadpool = "1.8.1" tabled = "0.17.0" colored = "3.0.0" +domain = {version = "0.10.3", default-features = false, features = ["zonefile"]} +tower = "0.4.13" + [dev-dependencies] assert_cmd = "2.0.16" predicates = "3.1.2" -testutil = { path = "../testutil" } +spaces_testutil = { path = "../testutil" } diff --git a/node/src/bin/space-cli.rs b/client/src/bin/space-cli.rs similarity index 66% rename from node/src/bin/space-cli.rs rename to client/src/bin/space-cli.rs index 2287ae1..c49a22e 100644 --- a/node/src/bin/space-cli.rs +++ b/client/src/bin/space-cli.rs @@ -1,33 +1,46 @@ extern crate core; -use std::{fs, path::PathBuf, str::FromStr}; +use std::{ + fs, io, + io::{Cursor, IsTerminal}, + path::PathBuf, +}; +use anyhow::anyhow; +use base64::Engine; use clap::{Parser, Subcommand}; use colored::{Color, Colorize}; +use domain::{ + base::{iana::Opcode, MessageBuilder, TreeCompressor}, + zonefile::inplace::{Entry, Zonefile}, +}; use jsonrpsee::{ core::{client::Error, ClientError}, http_client::{HttpClient, HttpClientBuilder}, }; -use protocol::{ - bitcoin::{Amount, FeeRate, OutPoint, Txid}, - hasher::KeyHasher, - slabel::SLabel, -}; -use spaced::{ +use serde::{Deserialize, Serialize}; +use spaces_client::{ config::{default_spaces_rpc_port, ExtendedNetwork}, + deserialize_base64, + format::{ + print_error_rpc_response, print_list_bidouts, print_list_spaces_response, + print_list_transactions, print_list_unspent, print_server_info, + print_wallet_balance_response, print_wallet_info, print_wallet_response, Format, + }, rpc::{ BidParams, ExecuteParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest, RpcWalletTxBuilder, SendCoinsParams, TransferSpacesParams, }, - store::Sha256, - wallets::AddressKind, + serialize_base64, + wallets::{AddressKind, WalletResponse}, +}; +use spaces_protocol::bitcoin::{Amount, FeeRate, OutPoint, Txid}; +use spaces_wallet::{ + bitcoin::secp256k1::schnorr::Signature, + export::WalletExport, + nostr::{NostrEvent, NostrTag}, + Listing, }; -use spaced::format::{print_error_rpc_response, print_list_bidouts, print_list_spaces_response, print_list_transactions, print_list_unspent, print_server_info, print_wallet_balance_response, print_wallet_info, print_wallet_response, Format}; -use spaced::rpc::SignedMessage; -use spaced::wallets::WalletResponse; -use wallet::bitcoin::secp256k1::schnorr::Signature; -use wallet::export::WalletExport; -use wallet::Listing; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -138,7 +151,7 @@ enum Commands { fee_rate: Option, }, /// Renew ownership of a space - #[command(name = "renew", )] + #[command(name = "renew")] Renew { /// Spaces to renew #[arg(display_order = 0)] @@ -216,28 +229,6 @@ enum Commands { #[arg(long, short)] fee_rate: Option, }, - /// Sign a message using the owner address of the specified space - #[command(name = "signmessage")] - SignMessage { - /// The space to use - space: String, - /// The message to sign - message: String, - }, - /// Verify a message using the owner address of the specified space - #[command(name = "verifymessage")] - VerifyMessage { - /// The space to verify - space: String, - - /// The message to verify - message: String, - - /// The signature to verify - #[arg(long)] - signature: String, - }, - /// Verify a listing #[command(name = "verifylisting")] VerifyListing { @@ -252,7 +243,50 @@ enum Commands { #[arg(long)] seller: String, }, + /// Sign any Nostr event using the space's private key + #[command(name = "signevent")] + SignEvent { + /// Space name (e.g., @example) + space: String, + + /// Path to a Nostr event json file (omit for stdin) + #[arg(short, long)] + input: Option, + + /// Include a space-tag and trust path data + #[arg(short, long)] + anchor: bool, + }, + /// Verify a signed Nostr event against the space's public key + #[command(name = "verifyevent")] + VerifyEvent { + /// Space name (e.g., @example) + space: String, + /// Path to a signed Nostr event json file (omit for stdin) + #[arg(short, long)] + input: Option, + }, + /// Sign a zone file turning it into a space-anchored Nostr event + #[command(name = "signzone")] + SignZone { + /// The space to use for signing the DNS file + space: String, + /// The DNS zone file path (omit for stdin) + input: Option, + /// Skip including bundled Merkle proof in the event. + #[arg(long)] + skip_anchor: bool, + }, + /// Updates the Merkle trust path for space-anchored Nostr events + #[command(name = "refreshanchor")] + RefreshAnchor { + /// Path to a Nostr event file (omit for stdin) + input: Option, + /// Prefer the most recent trust path (not recommended) + #[arg(long)] + prefer_recent: bool, + }, /// Get a spaceout - a Bitcoin output relevant to the Spaces protocol. #[command(name = "getspaceout")] GetSpaceOut { @@ -306,9 +340,6 @@ enum Commands { /// compatible with most bitcoin wallets #[command(name = "getnewaddress")] GetCoinAddress, - /// DNS encodes the space and calculates the SHA-256 hash - #[command(name = "hashspace")] - HashSpace { space: String }, } struct SpaceCli { @@ -322,6 +353,29 @@ struct SpaceCli { client: HttpClient, } +#[derive(Serialize, Deserialize)] +struct SignedDnsUpdate { + serial: u32, + space: String, + #[serde( + serialize_with = "serialize_base64", + deserialize_with = "deserialize_base64" + )] + packet: Vec, + signature: Signature, + #[serde(skip_serializing_if = "Option::is_none")] + proof: Option, +} + +#[derive(Serialize, Deserialize)] +struct Base64Bytes( + #[serde( + serialize_with = "serialize_base64", + deserialize_with = "deserialize_base64" + )] + Vec, +); + impl SpaceCli { async fn configure() -> anyhow::Result<(Self, Args)> { let mut args = Args::parse(); @@ -345,6 +399,66 @@ impl SpaceCli { )) } + async fn sign_event( + &self, + space: String, + event: NostrEvent, + anchor: bool, + most_recent: bool, + ) -> Result { + let mut result = self + .client + .wallet_sign_event(&self.wallet, &space, event) + .await?; + + if anchor { + result = self.add_anchor(result, most_recent).await? + } + + Ok(result) + } + async fn add_anchor( + &self, + mut event: NostrEvent, + most_recent: bool, + ) -> Result { + let space = match event.space() { + None => { + return Err(ClientError::Custom( + "A space tag is required to add an anchor".to_string(), + )) + } + Some(space) => space, + }; + + let spaceout = self + .client + .get_space(&space) + .await + .map_err(|e| ClientError::Custom(e.to_string()))? + .ok_or(ClientError::Custom(format!( + "Space not found \"{}\"", + space + )))?; + + event.proof = Some( + base64::prelude::BASE64_STANDARD.encode( + self.client + .prove_spaceout( + OutPoint { + txid: spaceout.txid, + vout: spaceout.spaceout.n as _, + }, + Some(most_recent), + ) + .await + .map_err(|e| ClientError::Custom(e.to_string()))? + .proof, + ), + ); + + Ok(event) + } async fn send_request( &self, req: Option, @@ -372,7 +486,6 @@ impl SpaceCli { ) .await?; - print_wallet_response(self.network.fallback_network(), result, self.format); Ok(()) } @@ -387,8 +500,6 @@ fn normalize_space(space: &str) -> String { } } - - #[tokio::main] async fn main() -> anyhow::Result<()> { let (cli, args) = SpaceCli::configure().await?; @@ -441,16 +552,7 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -fn hash_space(spaceish: &str) -> anyhow::Result { - let space = normalize_space(&spaceish); - let sname = SLabel::from_str(&space)?; - Ok(hex::encode(Sha256::hash(sname.as_ref()))) -} - -async fn handle_commands( - cli: &SpaceCli, - command: Commands, -) -> std::result::Result<(), ClientError> { +async fn handle_commands(cli: &SpaceCli, command: Commands) -> Result<(), ClientError> { match command { Commands::GetRollout { target_interval: target, @@ -463,8 +565,8 @@ async fn handle_commands( println!("{} sat", Amount::from_sat(response).to_sat()); } Commands::GetSpace { space } => { - let space_hash = hash_space(&space).map_err(|e| ClientError::Custom(e.to_string()))?; - let response = cli.client.get_space(&space_hash).await?; + let space = normalize_space(&space); + let response = cli.client.get_space(&space).await?; println!("{}", serde_json::to_string_pretty(&response)?); } Commands::GetSpaceOut { outpoint } => { @@ -512,7 +614,7 @@ async fn handle_commands( fee_rate, false, ) - .await? + .await? } Commands::Bid { space, @@ -529,7 +631,7 @@ async fn handle_commands( fee_rate, confirmed_only, ) - .await? + .await? } Commands::CreateBidOuts { pairs, fee_rate } => { cli.send_request(None, Some(pairs), fee_rate, false).await? @@ -548,7 +650,7 @@ async fn handle_commands( fee_rate, false, ) - .await? + .await? } Commands::Renew { spaces, fee_rate } => { let spaces: Vec<_> = spaces.into_iter().map(|s| normalize_space(&s)).collect(); @@ -561,7 +663,7 @@ async fn handle_commands( fee_rate, false, ) - .await? + .await? } Commands::Transfer { spaces, @@ -578,7 +680,7 @@ async fn handle_commands( fee_rate, false, ) - .await? + .await? } Commands::SendCoins { amount, @@ -594,7 +696,7 @@ async fn handle_commands( fee_rate, false, ) - .await? + .await? } Commands::SetRawFallback { mut space, @@ -612,7 +714,8 @@ async fn handle_commands( } }; - let space_script = protocol::script::SpaceScript::create_set_fallback(data.as_slice()); + let space_script = + spaces_protocol::script::SpaceScript::create_set_fallback(data.as_slice()); cli.send_request( Some(RpcWalletRequest::Execute(ExecuteParams { @@ -623,7 +726,7 @@ async fn handle_commands( fee_rate, false, ) - .await?; + .await?; } Commands::ListUnspent => { let utxos = cli.client.wallet_list_unspent(&cli.wallet).await?; @@ -670,24 +773,30 @@ async fn handle_commands( .wallet_bump_fee(&cli.wallet, txid, fee_rate, cli.skip_tx_check) .await?; print_wallet_response( - cli.network.fallback_network(), WalletResponse { - result: response, - }, cli.format); - } - Commands::HashSpace { space } => { - println!( - "{}", - hash_space(&space).map_err(|e| ClientError::Custom(e.to_string()))? + cli.network.fallback_network(), + WalletResponse { result: response }, + cli.format, ); } - Commands::Buy { space, price, signature, seller, fee_rate } => { + Commands::Buy { + space, + price, + signature, + seller, + fee_rate, + } => { let listing = Listing { space: normalize_space(&space), price, seller, - signature: Signature::from_slice(hex::decode(signature) - .map_err(|_| ClientError::Custom("Signature must be in hex format".to_string()))?.as_slice()) - .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?, + signature: Signature::from_slice( + hex::decode(signature) + .map_err(|_| { + ClientError::Custom("Signature must be in hex format".to_string()) + })? + .as_slice(), + ) + .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?, }; let result = cli .client @@ -696,57 +805,118 @@ async fn handle_commands( listing, fee_rate.map(|rate| FeeRate::from_sat_per_vb(rate).expect("valid fee rate")), cli.skip_tx_check, - ).await?; - print_wallet_response(cli.network.fallback_network(), WalletResponse { - result: vec![result], - }, cli.format + ) + .await?; + print_wallet_response( + cli.network.fallback_network(), + WalletResponse { + result: vec![result], + }, + cli.format, ); } - Commands::Sell { mut space, price, } => { + Commands::Sell { mut space, price } => { space = normalize_space(&space); - let result = cli - .client - .wallet_sell( - &cli.wallet, - space, - price, - ).await?; + let result = cli.client.wallet_sell(&cli.wallet, space, price).await?; println!("{}", serde_json::to_string_pretty(&result).expect("result")); } - Commands::VerifyListing { space, price, signature, seller } => { + Commands::VerifyListing { + space, + price, + signature, + seller, + } => { let listing = Listing { space: normalize_space(&space), price, seller, - signature: Signature::from_slice(hex::decode(signature) - .map_err(|_| ClientError::Custom("Signature must be in hex format".to_string()))?.as_slice()) - .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?, + signature: Signature::from_slice( + hex::decode(signature) + .map_err(|_| { + ClientError::Custom("Signature must be in hex format".to_string()) + })? + .as_slice(), + ) + .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?, }; - cli.client - .verify_listing(listing).await?; + cli.client.verify_listing(listing).await?; println!("{} Listing verified", "✓".color(Color::Green)); } - Commands::SignMessage { mut space, message } => { + Commands::SignEvent { + mut space, + input, + anchor, + } => { + let mut event = read_event(input) + .map_err(|e| ClientError::Custom(format!("input error: {}", e.to_string())))?; + space = normalize_space(&space); - let result = cli.client - .wallet_sign_message(&cli.wallet, &space, protocol::Bytes::new(message.as_bytes().to_vec())).await?; - println!("{}", result.signature); + match event.space() { + None if anchor => event + .tags + .insert(0, NostrTag(vec!["space".to_string(), space.clone()])), + Some(tag) => { + if tag != space { + return Err(ClientError::Custom(format!( + "Expected a space tag with value '{}', got '{}'", + space, tag + ))); + } + } + _ => {} + }; + + let result = cli.sign_event(space, event, anchor, false).await?; + println!("{}", serde_json::to_string(&result).expect("result")); } - Commands::VerifyMessage { mut space, message, signature } => { - space = normalize_space(&space); - let raw = hex::decode(signature) - .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?; - let signature = Signature::from_slice(raw.as_slice()) - .map_err(|_| ClientError::Custom("Invalid signature".to_string()))?; - cli.client.verify_message(SignedMessage { - space, - message: protocol::Bytes::new(message.as_bytes().to_vec()), - signature, - }).await?; - println!("{} Message verified", "✓".color(Color::Green)); + Commands::SignZone { + space, + input, + skip_anchor, + } => { + let update = encode_dns_update(&space, input) + .map_err(|e| ClientError::Custom(format!("Parse error: {}", e)))?; + let result = cli.sign_event(space, update, !skip_anchor, false).await?; + + println!("{}", serde_json::to_string(&result).expect("result")); } + Commands::RefreshAnchor { + input, + prefer_recent, + } => { + let event = read_event(input) + .map_err(|e| ClientError::Custom(format!("input error: {}", e.to_string())))?; + let space = match event.space() { + None => { + return Err(ClientError::Custom( + "Not a space-anchored event (no space tag)".to_string(), + )) + } + Some(space) => space, + }; + + let mut event = cli + .client + .verify_event(&space, event) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + event.proof = None; + event = cli.add_anchor(event, prefer_recent).await?; + + println!("{}", serde_json::to_string(&event).expect("result")); + } + Commands::VerifyEvent { space, input } => { + let event = read_event(input) + .map_err(|e| ClientError::Custom(format!("input error: {}", e.to_string())))?; + let event = cli + .client + .verify_event(&space, event) + .await + .map_err(|e| ClientError::Custom(e.to_string()))?; + println!("{}", serde_json::to_string(&event).expect("result")); + } } Ok(()) @@ -755,3 +925,51 @@ async fn handle_commands( fn default_spaced_rpc_url(chain: &ExtendedNetwork) -> String { format!("http://127.0.0.1:{}", default_spaces_rpc_port(chain)) } + +fn encode_dns_update(space: &str, zone_file: Option) -> anyhow::Result { + // domain crate panics if zone doesn't end in a new line + let zone = get_input(zone_file)? + "\n"; + + let mut builder = MessageBuilder::from_target(TreeCompressor::new(Vec::new()))?.authority(); + + builder.header_mut().set_opcode(Opcode::UPDATE); + + let mut cursor = Cursor::new(zone); + let mut reader = Zonefile::load(&mut cursor)?; + + while let Some(entry) = reader + .next_entry() + .or_else(|e| Err(anyhow!("Error reading zone entry: {}", e)))? + { + if let Entry::Record(record) = &entry { + builder.push(record)?; + } + } + + let msg = builder.finish(); + Ok(NostrEvent::new( + 871_222, + &base64::prelude::BASE64_STANDARD.encode(msg.as_slice()), + vec![NostrTag(vec!["space".to_string(), space.to_string()])], + )) +} + +fn read_event(file: Option) -> anyhow::Result { + let content = get_input(file)?; + let event: NostrEvent = serde_json::from_str(&content)?; + Ok(event) +} + +// Helper to handle file or stdin input +fn get_input(input: Option) -> anyhow::Result { + Ok(match input { + Some(file) => fs::read_to_string(file)?, + None => { + let input = io::stdin(); + match input.is_terminal() { + true => return Err(anyhow!("no input provided: specify file path or stdin")), + false => input.lines().collect::>()?, + } + } + }) +} diff --git a/node/src/bin/spaced.rs b/client/src/bin/spaced.rs similarity index 92% rename from node/src/bin/spaced.rs rename to client/src/bin/spaced.rs index 0f46f6d..1fa96c6 100644 --- a/node/src/bin/spaced.rs +++ b/client/src/bin/spaced.rs @@ -1,9 +1,9 @@ -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use anyhow::anyhow; use env_logger::Env; use log::error; -use spaced::{ +use spaces_client::{ config::{safe_exit, Args}, rpc::{AsyncChainState, RpcServerImpl, WalletLoadRequest, WalletManager}, source::{BitcoinBlockSource, BitcoinRpc}, @@ -83,6 +83,7 @@ impl Composer { 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()), self.shutdown.subscribe(), @@ -142,6 +143,7 @@ impl Composer { async fn create_async_store( rpc: BitcoinRpc, + anchors: Option, chain_state: LiveSnapshot, block_index: Option, shutdown: broadcast::Receiver<()>, @@ -150,7 +152,16 @@ async fn create_async_store( let async_store = AsyncChainState::new(tx); let client = reqwest::Client::new(); let handle = tokio::spawn(async move { - AsyncChainState::handler(&client, rpc, chain_state, block_index, rx, shutdown).await + AsyncChainState::handler( + &client, + rpc, + anchors, + chain_state, + block_index, + rx, + shutdown, + ) + .await }); (async_store, handle) } diff --git a/node/src/checker.rs b/client/src/checker.rs similarity index 95% rename from node/src/checker.rs rename to client/src/checker.rs index b1ed567..b5b028e 100644 --- a/node/src/checker.rs +++ b/client/src/checker.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use anyhow::anyhow; -use protocol::{ +use spaces_protocol::{ bitcoin::{OutPoint, Transaction}, hasher::{KeyHasher, SpaceKey}, prepare::{DataSource, TxContext}, @@ -147,14 +147,17 @@ impl DataSource for TxChecker<'_> { fn get_space_outpoint( &mut self, space_hash: &SpaceKey, - ) -> protocol::errors::Result> { + ) -> spaces_protocol::errors::Result> { match self.spaces.get(space_hash) { None => self.original.get_space_outpoint(space_hash.into()), Some(res) => Ok(res.clone()), } } - fn get_spaceout(&mut self, outpoint: &OutPoint) -> protocol::errors::Result> { + fn get_spaceout( + &mut self, + outpoint: &OutPoint, + ) -> spaces_protocol::errors::Result> { match self.spaceouts.get(outpoint) { None => self.original.get_spaceout(outpoint), Some(space_out) => Ok(space_out.clone()), diff --git a/node/src/node.rs b/client/src/client.rs similarity index 96% rename from node/src/node.rs rename to client/src/client.rs index 789fcc7..b6a1e59 100644 --- a/node/src/node.rs +++ b/client/src/client.rs @@ -1,11 +1,12 @@ -pub extern crate protocol; pub extern crate spacedb; +pub extern crate spaces_protocol; use std::{error::Error, fmt}; use anyhow::{anyhow, Result}; use bincode::{Decode, Encode}; -use protocol::{ +use serde::{Deserialize, Serialize}; +use spaces_protocol::{ bitcoin::{Amount, Block, BlockHash, OutPoint, Txid}, constants::{ChainAnchor, ROLLOUT_BATCH_SIZE, ROLLOUT_BLOCK_INTERVAL}, hasher::{BidKey, KeyHasher, OutpointKey, SpaceKey}, @@ -13,8 +14,7 @@ use protocol::{ validate::{TxChangeSet, UpdateKind, Validator}, Bytes, Covenant, FullSpaceOut, RevokeReason, SpaceOut, }; -use serde::{Deserialize, Serialize}; -use wallet::bitcoin::Transaction; +use spaces_wallet::bitcoin::Transaction; use crate::{ source::BitcoinRpcError, @@ -31,7 +31,7 @@ pub trait BlockSource { } #[derive(Debug, Clone)] -pub struct Node { +pub struct Client { validator: Validator, tx_data: bool, } @@ -76,7 +76,7 @@ impl fmt::Display for SyncError { impl Error for SyncError {} -impl Node { +impl Client { pub fn new(tx_data: bool) -> Self { Self { validator: Validator::new(), @@ -122,9 +122,9 @@ impl Node { tx: if self.tx_data { Some(TxData { position: 0, - raw: Bytes::new(protocol::bitcoin::consensus::encode::serialize( - &coinbase, - )), + raw: Bytes::new( + spaces_protocol::bitcoin::consensus::encode::serialize(&coinbase), + ), }) } else { None @@ -147,9 +147,9 @@ impl Node { tx: if self.tx_data { Some(TxData { position: position as u32, - raw: Bytes::new(protocol::bitcoin::consensus::encode::serialize( - &tx, - )), + raw: Bytes::new( + spaces_protocol::bitcoin::consensus::encode::serialize(&tx), + ), }) } else { None diff --git a/node/src/config.rs b/client/src/config.rs similarity index 97% rename from node/src/config.rs rename to client/src/config.rs index 6ccd6e4..9d95a62 100644 --- a/node/src/config.rs +++ b/client/src/config.rs @@ -15,8 +15,8 @@ use clap::{ use directories::ProjectDirs; use jsonrpsee::core::Serialize; use log::error; -use protocol::bitcoin::Network; use serde::Deserialize; +use spaces_protocol::bitcoin::Network; use toml::Value; use crate::{ @@ -76,6 +76,9 @@ pub struct Args { /// Index blocks including the full transaction data #[arg(long, env = "SPACED_BLOCK_INDEX_FULL", default_value = "false")] block_index_full: bool, + /// Skip maintaining historical root anchors + #[arg(long, env = "SPACED_SKIP_ANCHORS", default_value = "false")] + skip_anchors: bool, } #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, ValueEnum, Serialize, Deserialize)] @@ -176,6 +179,10 @@ impl Args { store: chain_store, }; + 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"); @@ -212,6 +219,8 @@ impl Args { block_index, block_index_full: args.block_index_full, num_workers: args.jobs as usize, + anchors_path, + synced: false, }) } diff --git a/node/src/format.rs b/client/src/format.rs similarity index 67% rename from node/src/format.rs rename to client/src/format.rs index 886ae3d..6e14512 100644 --- a/node/src/format.rs +++ b/client/src/format.rs @@ -2,16 +2,26 @@ use clap::ValueEnum; use colored::{Color, Colorize}; use jsonrpsee::core::Serialize; use serde::Deserialize; +use spaces_protocol::{ + bitcoin::{Amount, Network, OutPoint}, + Covenant, +}; +use spaces_wallet::{ + address::SpaceAddress, + bdk_wallet::KeychainKind, + bitcoin::{Address, Txid}, + tx_event::{ + BidEventDetails, BidoutEventDetails, OpenEventDetails, SendEventDetails, + TransferEventDetails, TxEventKind, + }, + Balance, DoubleUtxo, WalletInfo, WalletOutput, +}; use tabled::{Table, Tabled}; -use protocol::bitcoin::{Amount, Network, OutPoint}; -use protocol::{Covenant}; -use wallet::address::SpaceAddress; -use wallet::{Balance, DoubleUtxo, WalletInfo, WalletOutput}; -use wallet::bdk_wallet::KeychainKind; -use wallet::bitcoin::{Address, Txid}; -use wallet::tx_event::{BidEventDetails, BidoutEventDetails, OpenEventDetails, SendEventDetails, TransferEventDetails, TxEventKind}; -use crate::rpc::ServerInfo; -use crate::wallets::{ListSpacesResponse, TxInfo, TxResponse, WalletResponse}; + +use crate::{ + rpc::ServerInfo, + wallets::{ListSpacesResponse, TxInfo, TxResponse, WalletResponse}, +}; #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, ValueEnum, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -56,7 +66,7 @@ struct RegisteredSpaces { #[tabled(rename = "DAYS LEFT")] days_left: String, #[tabled(rename = "UTXO")] - utxo: OutPoint + utxo: OutPoint, } #[derive(Tabled)] @@ -91,17 +101,19 @@ fn format_days_left(current_block: u32, claim_height: Option) -> String { pub fn print_list_bidouts(bidouts: Vec, format: Format) { match format { Format::Text => { - let all : Vec<_> = bidouts.into_iter().map(|out| Bidout { - txid: out.spend.outpoint.txid, - vout_1: out.spend.outpoint.vout, - vout_2: out.auction.outpoint.vout, - confirmed: out.confirmed, - }).collect(); + let all: Vec<_> = bidouts + .into_iter() + .map(|out| Bidout { + txid: out.spend.outpoint.txid, + vout_1: out.spend.outpoint.vout, + vout_2: out.auction.outpoint.vout, + confirmed: out.confirmed, + }) + .collect(); println!("{}", ascii_table(all)); } Format::Json => { println!("{}", serde_json::to_string_pretty(&bidouts).unwrap()); - } } } @@ -117,17 +129,18 @@ pub fn print_list_transactions(txs: Vec, format: Format) { } } - - pub fn print_list_unspent(utxos: Vec, format: Format) { match format { Format::Text => { - let utxos : Vec<_> = utxos.iter().map(|utxo| UnspentOutput { - outpoint: utxo.output.outpoint, - confirmed: utxo.output.chain_position.is_confirmed(), - value: utxo.output.txout.value, - external: utxo.output.keychain == KeychainKind::External, - }).collect(); + let utxos: Vec<_> = utxos + .iter() + .map(|utxo| UnspentOutput { + outpoint: utxo.output.outpoint, + confirmed: utxo.output.chain_position.is_confirmed(), + value: utxo.output.txout.value, + external: utxo.output.keychain == KeychainKind::External, + }) + .collect(); println!("{}", ascii_table(utxos)) } Format::Json => { @@ -153,10 +166,7 @@ pub fn print_wallet_info(info: WalletInfo, format: Format) { match format { Format::Text => { println!("WALLET: {}", info.label); - println!(" Tip {}\n Birthday {}", - info.tip, - info.start_block - ); + println!(" Tip {}\n Birthday {}", info.tip, info.start_block); println!(" Public descriptors"); for desc in info.descriptors { @@ -184,9 +194,18 @@ pub fn print_wallet_balance_response(balance: Balance, format: Format) { match format { Format::Text => { println!("Balance: {}", balance.balance.to_sat()); - println!(" Confirmed {:>14}", balance.details.balance.confirmed.to_sat()); - println!(" Trusted pending {:>14}", balance.details.balance.trusted_pending.to_sat()); - println!(" Untrusted pending {:>14}", balance.details.balance.untrusted_pending.to_sat()); + println!( + " Confirmed {:>14}", + balance.details.balance.confirmed.to_sat() + ); + println!( + " Trusted pending {:>14}", + balance.details.balance.trusted_pending.to_sat() + ); + println!( + " Untrusted pending {:>14}", + balance.details.balance.untrusted_pending.to_sat() + ); println!(" Dust & in-auction {:>14}", balance.details.dust.to_sat()); } Format::Json => { @@ -195,7 +214,11 @@ pub fn print_wallet_balance_response(balance: Balance, format: Format) { } } -pub fn print_list_spaces_response(current_block: u32, response: ListSpacesResponse, format: Format) { +pub fn print_list_spaces_response( + current_block: u32, + response: ListSpacesResponse, + format: Format, +) { match format { Format::Text => { let mut outbids = Vec::new(); @@ -209,7 +232,11 @@ pub fn print_list_spaces_response(current_block: u32, response: ListSpacesRespon days_left: "".to_string(), }; match space.covenant { - Covenant::Bid { total_burned, claim_height, .. } => { + Covenant::Bid { + total_burned, + claim_height, + .. + } => { outbid.last_confirmed_bid = total_burned.to_sat(); outbid.days_left = format_days_left(current_block, claim_height); } @@ -227,9 +254,15 @@ pub fn print_list_spaces_response(current_block: u32, response: ListSpacesRespon claim_at: "--".to_string(), }; match space.covenant { - Covenant::Bid { total_burned, claim_height, .. } => { + Covenant::Bid { + total_burned, + claim_height, + .. + } => { winning.bid = total_burned.to_sat(); - winning.claim_at = claim_height.map(|h| h.to_string()).unwrap_or("--".to_string()); + winning.claim_at = claim_height + .map(|h| h.to_string()) + .unwrap_or("--".to_string()); winning.days_left = format_days_left(current_block, claim_height); if winning.days_left == "0.00" { winning.days_left = "Ready to claim".to_string(); @@ -250,14 +283,14 @@ pub fn print_list_spaces_response(current_block: u32, response: ListSpacesRespon match &space.covenant { Covenant::Transfer { expire_height, .. } => { registered.expire_at = *expire_height as _; - registered.days_left = format_days_left(current_block, Some(*expire_height)); + registered.days_left = + format_days_left(current_block, Some(*expire_height)); } _ => {} } owned.push(registered); } - if !outbids.is_empty() { println!("⚠️ OUTBID ({} spaces): ", outbids.len().to_string().bold()); let table = ascii_table(outbids); @@ -265,13 +298,21 @@ pub fn print_list_spaces_response(current_block: u32, response: ListSpacesRespon } if !winnings.is_empty() { - println!("{} WINNING ({} spaces):","✓".color(Color::Green), winnings.len().to_string().bold()); + println!( + "{} WINNING ({} spaces):", + "✓".color(Color::Green), + winnings.len().to_string().bold() + ); let table = ascii_table(winnings); println!("{}", table); } if !owned.is_empty() { - println!("{} ({} spaces): ", "🔑 OWNED", owned.len().to_string().bold()); + println!( + "{} ({} spaces): ", + "🔑 OWNED", + owned.len().to_string().bold() + ); let table = ascii_table(owned); println!("{}", table); } @@ -292,13 +333,19 @@ pub fn print_wallet_response_text(network: Network, response: WalletResponse) { for tx in response.result { if tx.events.iter().any(|event| match event.kind { - TxEventKind::Open | TxEventKind::Bid | TxEventKind::Register | - TxEventKind::Transfer | TxEventKind::Send | TxEventKind::Renew | TxEventKind::Buy - => true, + TxEventKind::Open + | TxEventKind::Bid + | TxEventKind::Register + | TxEventKind::Transfer + | TxEventKind::Send + | TxEventKind::Renew + | TxEventKind::Buy => true, _ => false, }) { main_txs.push(tx); - } else { secondary_txs.push(tx); } + } else { + secondary_txs.push(tx); + } } for tx in main_txs { @@ -328,7 +375,7 @@ pub fn print_error_rpc_response(code: i32, message: String, format: Format) { fn print_tx_response(network: Network, response: TxResponse) { match response.error { None => { - println!("{} Transaction {}","✓".color(Color::Green), response.txid); + println!("{} Transaction {}", "✓".color(Color::Green), response.txid); } Some(errors) => { println!("⚠️ Transaction failed to broadcast"); @@ -341,51 +388,57 @@ fn print_tx_response(network: Network, response: TxResponse) { } for event in response.events { - println!(" - {} {}", capitalize(event.kind.to_string()), event.space.unwrap_or("".to_string())); + println!( + " - {} {}", + capitalize(event.kind.to_string()), + event.space.unwrap_or("".to_string()) + ); match event.kind { TxEventKind::Open => { - let open_details: OpenEventDetails = serde_json::from_value( - event.details.expect("details")) - .expect("deserialize open event"); + let open_details: OpenEventDetails = + serde_json::from_value(event.details.expect("details")) + .expect("deserialize open event"); println!(" Initial bid: {}", open_details.initial_bid.to_sat()); } TxEventKind::Bid => { - let bid_details: BidEventDetails = serde_json::from_value( - event.details.expect("details")) - .expect("deserialize bid event"); - println!(" New bid: {} (previous {})", - bid_details.current_bid.to_sat(), - bid_details.previous_bid.to_sat() + let bid_details: BidEventDetails = + serde_json::from_value(event.details.expect("details")) + .expect("deserialize bid event"); + println!( + " New bid: {} (previous {})", + bid_details.current_bid.to_sat(), + bid_details.previous_bid.to_sat() ); } TxEventKind::Send => { - let send_details: SendEventDetails = serde_json::from_value( - event.details.expect("details")) - .expect("deserialize send event"); + let send_details: SendEventDetails = + serde_json::from_value(event.details.expect("details")) + .expect("deserialize send event"); - let addr = Address::from_script(send_details.recipient_script_pubkey.as_script(), network) - .expect("valid address"); + let addr = + Address::from_script(send_details.recipient_script_pubkey.as_script(), network) + .expect("valid address"); println!(" Amount: {}", send_details.amount.to_sat()); println!(" Recipient: {}", addr); } TxEventKind::Transfer => { - let transfer_details: TransferEventDetails = serde_json::from_value( - event.details.expect("details")) - .expect("deserialize transfer event"); + let transfer_details: TransferEventDetails = + serde_json::from_value(event.details.expect("details")) + .expect("deserialize transfer event"); let addr = SpaceAddress( Address::from_script(transfer_details.script_pubkey.as_script(), network) - .expect("valid address") + .expect("valid address"), ); println!(" Recipient: {}", addr); } TxEventKind::Bidout => { - let bidout: BidoutEventDetails = serde_json::from_value( - event.details.expect("details")) - .expect("deserialize bidout event"); + let bidout: BidoutEventDetails = + serde_json::from_value(event.details.expect("details")) + .expect("deserialize bidout event"); println!(" Count: {}", bidout.count); } _ => {} diff --git a/client/src/lib.rs b/client/src/lib.rs new file mode 100644 index 0000000..471e02f --- /dev/null +++ b/client/src/lib.rs @@ -0,0 +1,61 @@ +extern crate core; + +// needed for testutil +pub extern crate jsonrpsee; +pub extern crate log; + +use std::time::{Duration, Instant}; + +use base64::Engine; +use serde::{Deserialize, Deserializer, Serializer}; + +mod checker; +pub mod client; +pub mod config; +pub mod format; +pub mod rpc; +pub mod source; +pub mod store; +pub mod sync; +pub mod wallets; + +fn std_wait(mut predicate: F, wait: Duration) +where + F: FnMut() -> bool, +{ + let start = Instant::now(); + loop { + if predicate() { + break; + } + if start.elapsed() >= wait { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +pub fn serialize_base64(bytes: &Vec, serializer: S) -> Result +where + S: Serializer, +{ + if serializer.is_human_readable() { + serializer.serialize_str(&base64::prelude::BASE64_STANDARD.encode(bytes)) + } else { + serializer.serialize_bytes(bytes) + } +} + +pub fn deserialize_base64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + if deserializer.is_human_readable() { + let s = String::deserialize(deserializer)?; + base64::prelude::BASE64_STANDARD + .decode(&s) + .map_err(serde::de::Error::custom) + } else { + Vec::::deserialize(deserializer) + } +} diff --git a/node/src/rpc.rs b/client/src/rpc.rs similarity index 72% rename from node/src/rpc.rs rename to client/src/rpc.rs index af94ed9..a205376 100644 --- a/node/src/rpc.rs +++ b/client/src/rpc.rs @@ -1,5 +1,6 @@ use std::{ - collections::BTreeMap, fs, io::Write, net::SocketAddr, path::PathBuf, str::FromStr, sync::Arc, + collections::BTreeMap, fs, fs::File, io::Write, net::SocketAddr, path::PathBuf, str::FromStr, + sync::Arc, }; use anyhow::{anyhow, Context}; @@ -13,34 +14,53 @@ use bdk::{ miniscript::Tap, KeychainKind, }; -use jsonrpsee::{core::async_trait, proc_macros::rpc, server::Server, types::ErrorObjectOwned}; +use jsonrpsee::{ + core::async_trait, + proc_macros::rpc, + server::{middleware::http::ProxyGetRequestLayer, Server}, + types::ErrorObjectOwned, +}; use log::info; -use protocol::{bitcoin, bitcoin::{ - bip32::Xpriv, - Network::{Regtest, Testnet}, - OutPoint, -}, constants::ChainAnchor, hasher::{BaseHash, KeyHasher, SpaceKey}, prepare::DataSource, slabel::SLabel, validate::TxChangeSet, Bytes, FullSpaceOut, SpaceOut}; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use spacedb::{encode::SubTreeEncoder, tx::ProofType}; +use spaces_protocol::{ + bitcoin, + bitcoin::{ + bip32::Xpriv, + Network::{Regtest, Testnet}, + OutPoint, + }, + constants::ChainAnchor, + hasher::{BaseHash, KeyHasher, OutpointKey, SpaceKey}, + prepare::DataSource, + slabel::SLabel, + validate::TxChangeSet, + Bytes, Covenant, FullSpaceOut, SpaceOut, +}; +use spaces_wallet::{ + bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash as BitcoinHash, + export::WalletExport, nostr::NostrEvent, Balance, DoubleUtxo, Listing, SpacesWallet, + WalletConfig, WalletDescriptors, WalletInfo, WalletOutput, +}; use tokio::{ select, sync::{broadcast, mpsc, oneshot, RwLock}, task::JoinSet, }; -use protocol::bitcoin::secp256k1; -use wallet::{bdk_wallet as bdk, bdk_wallet::template::Bip86, bitcoin::hashes::Hash, export::WalletExport, Balance, DoubleUtxo, Listing, SpacesWallet, WalletConfig, WalletDescriptors, WalletInfo, WalletOutput}; use crate::{ checker::TxChecker, + client::{BlockMeta, TxEntry}, config::ExtendedNetwork, - node::{BlockMeta, TxEntry}, + deserialize_base64, serialize_base64, source::BitcoinRpc, store::{ChainState, LiveSnapshot, RolloutEntry, Sha256}, + sync::{COMMIT_BLOCK_INTERVAL, ROOT_ANCHORS_COUNT}, wallets::{ - AddressKind, RpcWallet, TxInfo, TxResponse, WalletCommand, + AddressKind, ListSpacesResponse, RpcWallet, TxInfo, TxResponse, WalletCommand, WalletResponse, }, }; -use crate::wallets::ListSpacesResponse; pub(crate) type Responder = oneshot::Sender; @@ -51,10 +71,13 @@ pub struct ServerInfo { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SignedMessage { - pub space: String, - pub message: protocol::Bytes, - pub signature: secp256k1::schnorr::Signature, +pub struct RootAnchor { + #[serde( + serialize_with = "serialize_hash", + deserialize_with = "deserialize_hash" + )] + pub root: spaces_protocol::hasher::Hash, + pub block: ChainAnchor, } pub enum ChainStateCommand { @@ -69,7 +92,6 @@ pub enum ChainStateCommand { hash: SpaceKey, resp: Responder>>, }, - GetSpaceout { outpoint: OutPoint, resp: Responder>>, @@ -98,9 +120,22 @@ pub enum ChainStateCommand { listing: Listing, resp: Responder>, }, - VerifyMessage { - msg: SignedMessage, - resp: Responder>, + VerifyEvent { + space: String, + event: NostrEvent, + resp: Responder>, + }, + ProveSpaceout { + outpoint: OutPoint, + prefer_recent: bool, + resp: Responder>, + }, + ProveSpaceOutpoint { + space_or_hash: String, + resp: Responder>, + }, + GetRootAnchors { + resp: Responder>>, }, } @@ -156,11 +191,20 @@ pub trait Rpc { #[method(name = "walletimport")] async fn wallet_import(&self, wallet: WalletExport) -> Result<(), ErrorObjectOwned>; - #[method(name = "verifymessage")] - async fn verify_message(&self, msg: SignedMessage) -> Result<(), ErrorObjectOwned>; + #[method(name = "verifyevent")] + async fn verify_event( + &self, + space: &str, + event: NostrEvent, + ) -> Result; - #[method(name = "walletsignmessage")] - async fn wallet_sign_message(&self, wallet: &str, space: &str, msg: protocol::Bytes) -> Result; + #[method(name = "walletsignevent")] + async fn wallet_sign_event( + &self, + wallet: &str, + space: &str, + event: NostrEvent, + ) -> Result; #[method(name = "walletgetinfo")] async fn wallet_get_info(&self, name: &str) -> Result; @@ -212,10 +256,23 @@ pub trait Rpc { ) -> Result; #[method(name = "verifylisting")] - async fn verify_listing( + async fn verify_listing(&self, listing: Listing) -> Result<(), ErrorObjectOwned>; + + #[method(name = "provespaceout")] + async fn prove_spaceout( &self, - listing: Listing, - ) -> Result<(), ErrorObjectOwned>; + outpoint: OutPoint, + prefer_recent: Option, + ) -> Result; + + #[method(name = "provespaceoutpoint")] + async fn prove_space_outpoint( + &self, + space_or_hash: &str, + ) -> Result; + + #[method(name = "getrootanchors")] + async fn get_root_anchors(&self) -> Result, ErrorObjectOwned>; #[method(name = "walletlisttransactions")] async fn wallet_list_transactions( @@ -234,8 +291,10 @@ pub trait Rpc { ) -> Result; #[method(name = "walletlistspaces")] - async fn wallet_list_spaces(&self, wallet: &str) - -> Result; + async fn wallet_list_spaces( + &self, + wallet: &str, + ) -> Result; #[method(name = "walletlistunspent")] async fn wallet_list_unspent( @@ -331,6 +390,44 @@ pub struct RpcServerImpl { client: reqwest::Client, } +#[derive(Clone, Serialize, Deserialize)] +pub struct ProofResult { + pub root: Bytes, + #[serde( + serialize_with = "serialize_base64", + deserialize_with = "deserialize_base64" + )] + pub proof: Vec, +} + +fn serialize_hash( + bytes: &spaces_protocol::hasher::Hash, + serializer: S, +) -> Result +where + S: Serializer, +{ + if serializer.is_human_readable() { + serializer.serialize_str(&hex::encode(bytes)) + } else { + serializer.serialize_bytes(bytes) + } +} + +fn deserialize_hash<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let mut bytes = [0u8; 32]; + if deserializer.is_human_readable() { + let s = String::deserialize(deserializer)?; + hex::decode_to_slice(s, &mut bytes).map_err(serde::de::Error::custom)?; + } else { + spaces_protocol::hasher::Hash::deserialize(deserializer)?; + } + Ok(bytes) +} + #[derive(Clone)] pub struct WalletManager { pub data_dir: PathBuf, @@ -569,9 +666,20 @@ impl RpcServerImpl { addrs: Vec, signal: broadcast::Sender<()>, ) -> anyhow::Result<()> { - let mut listeners: Vec = Vec::with_capacity(addrs.len()); + let mut listeners: Vec<_> = Vec::with_capacity(addrs.len()); + for addr in addrs.iter() { - let server = Server::builder().build(addr).await?; + let service_builder = tower::ServiceBuilder::new() + .layer(ProxyGetRequestLayer::new( + "/root-anchors.json", + "getrootanchors", + )?) + .layer(ProxyGetRequestLayer::new("/", "getserverinfo")?); + + let server = Server::builder() + .set_http_middleware(service_builder) + .build(addr) + .await?; listeners.push(server); } @@ -727,6 +835,30 @@ impl RpcServer for RpcServerImpl { }) } + async fn verify_event( + &self, + space: &str, + event: NostrEvent, + ) -> Result { + self.store + .verify_event(space, event) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn wallet_sign_event( + &self, + wallet: &str, + space: &str, + event: NostrEvent, + ) -> Result { + self.wallet(&wallet) + .await? + .send_sign_event(space, event) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + async fn wallet_get_info(&self, wallet: &str) -> Result { self.wallet(&wallet) .await? @@ -734,7 +866,6 @@ impl RpcServer for RpcServerImpl { .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } - async fn wallet_export(&self, name: &str) -> Result { self.wallet_manager .export_wallet(name) @@ -752,6 +883,7 @@ impl RpcServer for RpcServerImpl { ErrorObjectOwned::owned(RPC_WALLET_NOT_LOADED, error.to_string(), None::) }) } + async fn wallet_send_request( &self, wallet: &str, @@ -792,7 +924,13 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } - async fn wallet_buy(&self, wallet: &str, listing: Listing, fee_rate: Option, skip_tx_check: bool) -> Result { + async fn wallet_buy( + &self, + wallet: &str, + listing: Listing, + fee_rate: Option, + skip_tx_check: bool, + ) -> Result { self.wallet(&wallet) .await? .send_buy(listing, fee_rate, skip_tx_check) @@ -800,7 +938,12 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } - async fn wallet_sell(&self, wallet: &str, space: String, amount: u64) -> Result { + async fn wallet_sell( + &self, + wallet: &str, + space: String, + amount: u64, + ) -> Result { self.wallet(&wallet) .await? .send_sell(space, amount) @@ -808,24 +951,37 @@ impl RpcServer for RpcServerImpl { .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } - async fn wallet_sign_message(&self, wallet: &str, space: &str, msg: Bytes) -> Result { - self.wallet(&wallet) - .await? - .send_sign_message(space, msg) + async fn verify_listing(&self, listing: Listing) -> Result<(), ErrorObjectOwned> { + self.store + .verify_listing(listing) .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } - async fn verify_listing(&self, listing: Listing) -> Result<(), ErrorObjectOwned> { + async fn prove_spaceout( + &self, + outpoint: OutPoint, + prefer_recent: Option, + ) -> Result { self.store - .verify_listing(listing) + .prove_spaceout(outpoint, prefer_recent.unwrap_or(false)) + .await + .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) + } + + async fn prove_space_outpoint( + &self, + space_or_hash: &str, + ) -> Result { + self.store + .prove_space_outpoint(space_or_hash) .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } - async fn verify_message(&self, msg: SignedMessage) -> Result<(), ErrorObjectOwned> { + async fn get_root_anchors(&self) -> Result, ErrorObjectOwned> { self.store - .verify_message(msg) + .get_root_anchors() .await .map_err(|error| ErrorObjectOwned::owned(-1, error.to_string(), None::)) } @@ -970,6 +1126,7 @@ impl AsyncChainState { pub async fn handle_command( client: &reqwest::Client, rpc: &BitcoinRpc, + anchors_path: &Option, chain_state: &mut LiveSnapshot, block_index: &mut Option, cmd: ChainStateCommand, @@ -1030,19 +1187,167 @@ impl AsyncChainState { _ = resp.send(rollouts); } ChainStateCommand::VerifyListing { listing, resp } => { - _ = resp.send(SpacesWallet::verify_listing::(chain_state, &listing).map(|_| ())); + _ = resp.send( + SpacesWallet::verify_listing::(chain_state, &listing).map(|_| ()), + ); + } + ChainStateCommand::VerifyEvent { space, event, resp } => { + _ = resp.send(SpacesWallet::verify_event::( + chain_state, + &space, + event, + )); + } + ChainStateCommand::ProveSpaceout { + prefer_recent, + outpoint, + resp, + } => { + _ = resp.send(Self::handle_prove_spaceout( + chain_state, + outpoint, + prefer_recent, + )); } - ChainStateCommand::VerifyMessage { msg, resp } => { - _ = resp.send(SpacesWallet::verify_message::( - chain_state, &msg.space, msg.message.as_slice(), &msg.signature - ).map(|_| ())); + ChainStateCommand::ProveSpaceOutpoint { + space_or_hash, + resp, + } => { + _ = resp.send(Self::handle_prove_space_outpoint( + chain_state, + &space_or_hash, + )); } + ChainStateCommand::GetRootAnchors { resp } => { + _ = resp.send(Self::handle_get_anchor(anchors_path, chain_state)); + } + } + } + + fn handle_get_anchor( + anchors_path: &Option, + state: &mut LiveSnapshot, + ) -> 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)))?; + return Ok(anchors); } + + let snapshot = state.inner()?; + let root = snapshot.compute_root()?; + let meta: ChainAnchor = snapshot.metadata().try_into()?; + Ok(vec![RootAnchor { + root, + block: ChainAnchor { + hash: meta.hash, + height: meta.height, + }, + }]) + } + + fn handle_prove_space_outpoint( + state: &mut LiveSnapshot, + space_or_hash: &str, + ) -> anyhow::Result { + let key = get_space_key(space_or_hash)?; + let snapshot = state.inner()?; + + // warm up hash cache + let root = snapshot.compute_root()?; + let proof = snapshot.prove(&[key.into()], ProofType::Standard)?; + + let mut buf = vec![0u8; 4096]; + let offset = proof.write_to_slice(&mut buf)?; + buf.truncate(offset); + + Ok(ProofResult { + proof: buf, + root: Bytes::new(root.to_vec()), + }) + } + + /// Determines the optimal snapshot block height for creating a Merkle proof. + /// + /// This function finds a suitable historical snapshot that: + /// 1. Is not older than when the space was last updated. + /// 2. Falls within [ROOT_ANCHORS_COUNT] range + /// 3. Skips the oldest trust anchors to prevent the proof from becoming stale too quickly. + /// + /// Parameters: + /// - last_update: Block height when the space was last updated + /// - tip: Current blockchain tip height + /// + /// Returns: Target block height aligned to [COMMIT_BLOCK_INTERVAL] + fn compute_target_snapshot(last_update: u32, tip: u32) -> u32 { + const SAFETY_MARGIN: u32 = 8; // Skip oldest trust anchors to prevent proof staleness + const USABLE_ANCHORS: u32 = ROOT_ANCHORS_COUNT - SAFETY_MARGIN; + + // Align block heights to commit intervals + let last_update_aligned = + last_update.div_ceil(COMMIT_BLOCK_INTERVAL) * COMMIT_BLOCK_INTERVAL; + let current_tip_aligned = (tip / COMMIT_BLOCK_INTERVAL) * COMMIT_BLOCK_INTERVAL; + + // Calculate the oldest allowed snapshot while maintaining safety margin + let lookback_window = (USABLE_ANCHORS - 1) * COMMIT_BLOCK_INTERVAL; + let oldest_allowed_snapshot = current_tip_aligned.saturating_sub(lookback_window); + + // Choose the most recent of last update or oldest allowed snapshot + // to ensure both data freshness and proof verifiability + std::cmp::max(last_update_aligned, oldest_allowed_snapshot) + } + + fn handle_prove_spaceout( + state: &mut LiveSnapshot, + outpoint: OutPoint, + prefer_recent: bool, + ) -> anyhow::Result { + let key = OutpointKey::from_outpoint::(outpoint); + + let proof = if !prefer_recent { + let spaceout = + match state.get_spaceout(&outpoint)? { + Some(spaceot) => spaceot, + None => return Err(anyhow!( + "Cannot find older proofs for a non-existent utxo (try with oldest: false)" + )), + }; + let target_snapshot = match spaceout.space.as_ref() { + 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 last_update = expire_height.saturating_sub(spaces_protocol::constants::RENEWAL_INTERVAL); + Self::compute_target_snapshot(last_update, tip) + } + _ => return Err(anyhow!("Cannot find older proofs for a non-registered space (try with oldest: false)")), + } + }; + state.prove_with_snapshot(&[key.into()], target_snapshot)? + } else { + let snapshot = state.inner()?; + snapshot.prove(&[key.into()], ProofType::Standard)? + }; + + let root = proof.compute_root()?.to_vec(); + info!("Proving with root anchor {}", hex::encode(root.as_slice())); + let mut buf = vec![0u8; 4096]; + let offset = proof.write_to_slice(&mut buf)?; + buf.truncate(offset); + + Ok(ProofResult { + proof: buf, + root: Bytes::new(root), + }) } pub async fn handler( client: &reqwest::Client, rpc: BitcoinRpc, + anchors_path: Option, mut chain_state: LiveSnapshot, mut block_index: Option, mut rx: mpsc::Receiver, @@ -1054,7 +1359,7 @@ impl AsyncChainState { break; } Some(cmd) = rx.recv() => { - Self::handle_command(client, &rpc, &mut chain_state, &mut block_index, cmd).await; + Self::handle_command(client, &rpc, &anchors_path, &mut chain_state, &mut block_index, cmd).await; } } } @@ -1078,10 +1383,49 @@ impl AsyncChainState { resp_rx.await? } - pub async fn verify_message(&self, msg: SignedMessage) -> anyhow::Result<()> { + pub async fn verify_event(&self, space: &str, event: NostrEvent) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::VerifyEvent { + space: space.to_string(), + event, + resp, + }) + .await?; + resp_rx.await? + } + + pub async fn prove_spaceout( + &self, + outpoint: OutPoint, + prefer_recent: bool, + ) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::ProveSpaceout { + outpoint, + prefer_recent: prefer_recent, + resp, + }) + .await?; + resp_rx.await? + } + + pub async fn prove_space_outpoint(&self, space_or_hash: &str) -> anyhow::Result { + let (resp, resp_rx) = oneshot::channel(); + self.sender + .send(ChainStateCommand::ProveSpaceOutpoint { + space_or_hash: space_or_hash.to_string(), + resp, + }) + .await?; + resp_rx.await? + } + + pub async fn get_root_anchors(&self) -> anyhow::Result> { let (resp, resp_rx) = oneshot::channel(); self.sender - .send(ChainStateCommand::VerifyMessage { msg, resp }) + .send(ChainStateCommand::GetRootAnchors { resp }) .await?; resp_rx.await? } diff --git a/node/src/source.rs b/client/src/source.rs similarity index 98% rename from node/src/source.rs rename to client/src/source.rs index 363f56a..ec91e91 100644 --- a/node/src/source.rs +++ b/client/src/source.rs @@ -12,17 +12,16 @@ use std::{ use base64::Engine; use bitcoin::{Block, BlockHash, Txid}; use hex::FromHexError; -use log::{error}; -use protocol::constants::ChainAnchor; +use log::error; use reqwest::StatusCode; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; +use spaces_protocol::constants::ChainAnchor; +use spaces_wallet::{bitcoin, bitcoin::Transaction}; use threadpool::ThreadPool; use tokio::time::Instant; -use wallet::{bitcoin, bitcoin::Transaction}; -use crate::node::BlockSource; -use crate::std_wait; +use crate::{client::BlockSource, std_wait}; const BITCOIN_RPC_IN_WARMUP: i32 = -28; // Client still warming up const BITCOIN_RPC_CLIENT_NOT_CONNECTED: i32 = -9; // Bitcoin is not connected @@ -456,8 +455,10 @@ impl BlockFetcher { Ok(t) => t, Err(e) => { _ = task_sender.send(BlockEvent::Error(e)); - std_wait(|| current_task.load(Ordering::SeqCst) != job_id, - Duration::from_secs(1)); + std_wait( + || current_task.load(Ordering::SeqCst) != job_id, + Duration::from_secs(1), + ); continue; } }; @@ -479,8 +480,10 @@ impl BlockFetcher { } Err(e) if matches!(e, BlockFetchError::RpcError(_)) => { _ = task_sender.send(BlockEvent::Error(e)); - std_wait(|| current_task.load(Ordering::SeqCst) != job_id, - Duration::from_secs(1)); + std_wait( + || current_task.load(Ordering::SeqCst) != job_id, + Duration::from_secs(1), + ); continue; } Err(e) => { diff --git a/node/src/store.rs b/client/src/store.rs similarity index 80% rename from node/src/store.rs rename to client/src/store.rs index 1d28947..e84f3a6 100644 --- a/node/src/store.rs +++ b/client/src/store.rs @@ -1,31 +1,34 @@ use std::{ - collections::{BTreeMap, BTreeSet}, + collections::{BTreeMap, BTreeSet, HashMap}, + fs, fs::OpenOptions, io, io::ErrorKind, mem, - path::PathBuf, + path::{Path, PathBuf}, sync::{Arc, RwLock}, }; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use bincode::{config, Decode, Encode}; use jsonrpsee::core::Serialize; -use protocol::{ - bitcoin::OutPoint, - constants::{ChainAnchor, ROLLOUT_BATCH_SIZE}, - hasher::{BidKey, KeyHash, OutpointKey, SpaceKey}, - prepare::DataSource, - Covenant, FullSpaceOut, SpaceOut, -}; use serde::Deserialize; use spacedb::{ db::{Database, SnapshotIterator}, fs::FileBackend, - tx::{KeyIterator, ReadTransaction, WriteTransaction}, + subtree::SubTree, + tx::{KeyIterator, ProofType, ReadTransaction, WriteTransaction}, Configuration, Hash, NodeHasher, Sha256Hasher, }; -use protocol::bitcoin::BlockHash; +use spaces_protocol::{ + bitcoin::{BlockHash, OutPoint}, + constants::{ChainAnchor, ROLLOUT_BATCH_SIZE}, + hasher::{BidKey, KeyHash, OutpointKey, SpaceKey}, + prepare::DataSource, + Covenant, FullSpaceOut, SpaceOut, +}; + +use crate::rpc::RootAnchor; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RolloutEntry { @@ -94,6 +97,38 @@ impl Store { Ok(self.0.begin_write()?) } + pub fn update_anchors(&self, file_path: &Path, count: u32) -> Result> { + let previous: Vec = match fs::read(file_path) { + Ok(bytes) => serde_json::from_slice(&bytes)?, + Err(e) if e.kind() == io::ErrorKind::NotFound => Vec::new(), + Err(e) => return Err(e.into()), + }; + let prev_map: HashMap<(BlockHash, u32), RootAnchor> = previous + .into_iter() + .map(|anchor| ((anchor.block.hash, anchor.block.height), anchor)) + .collect(); + + let mut anchors = Vec::new(); + for snap in self.0.iter().take(count as _) { + let mut snap = snap?; + let anchor: ChainAnchor = snap.metadata().try_into()?; + + if let Some(existing) = prev_map.get(&(anchor.hash, anchor.height)) { + anchors.push(existing.clone()); + } else { + let root = snap.compute_root()?; + anchors.push(RootAnchor { + root, + block: anchor, + }); + } + } + + let updated = serde_json::to_vec_pretty(&anchors)?; + fs::write(file_path, updated)?; + Ok(anchors) + } + pub fn begin(&self, genesis_block: &ChainAnchor) -> Result { let snapshot = self.0.begin_read()?; let anchor: ChainAnchor = if snapshot.metadata().len() == 0 { @@ -157,7 +192,7 @@ pub trait ChainState { fn get_space_info( &mut self, - space_hash: &protocol::hasher::SpaceKey, + space_hash: &spaces_protocol::hasher::SpaceKey, ) -> anyhow::Result>; } @@ -211,7 +246,30 @@ impl LiveSnapshot { }; } - pub fn inner(&mut self) -> anyhow::Result<&ReadTx> { + 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; @@ -219,7 +277,7 @@ impl LiveSnapshot { self.update_snapshot(version)?; } - Ok(&self.snapshot.1) + Ok(&mut self.snapshot.1) } pub fn insert, T: Encode>(&self, key: K, value: T) { @@ -235,8 +293,8 @@ impl LiveSnapshot { 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())) - })?; + spacedb::Error::IO(io::Error::new(ErrorKind::Other, e.to_string())) + })?; Ok(Some(decoded)) } None => Ok(None), @@ -428,24 +486,27 @@ impl DataSource for LiveSnapshot { fn get_space_outpoint( &mut self, space_hash: &SpaceKey, - ) -> protocol::errors::Result> { - let result: Option = self - .get(*space_hash) - .map_err(|err| protocol::errors::Error::IO(format!("getspaceoutpoint: {}", err.to_string())))?; + ) -> spaces_protocol::errors::Result> { + let result: Option = self.get(*space_hash).map_err(|err| { + spaces_protocol::errors::Error::IO(format!("getspaceoutpoint: {}", err.to_string())) + })?; Ok(result.map(|out| out.into())) } - fn get_spaceout(&mut self, outpoint: &OutPoint) -> protocol::errors::Result> { + fn get_spaceout( + &mut self, + outpoint: &OutPoint, + ) -> spaces_protocol::errors::Result> { let h = OutpointKey::from_outpoint::(*outpoint); - let result = self - .get(h) - .map_err(|err| protocol::errors::Error::IO(format!("getspaceout: {}", err.to_string())))?; + let result = self.get(h).map_err(|err| { + spaces_protocol::errors::Error::IO(format!("getspaceout: {}", err.to_string())) + })?; Ok(result) } } -impl protocol::hasher::KeyHasher for Sha256 { - fn hash(data: &[u8]) -> protocol::hasher::Hash { +impl spaces_protocol::hasher::KeyHasher for Sha256 { + fn hash(data: &[u8]) -> spaces_protocol::hasher::Hash { Sha256Hasher::hash(data) } } @@ -503,8 +564,8 @@ impl Iterator for KeyRolloutIterator { struct MergingIterator where - I1: Iterator>, - I2: Iterator>, + I1: Iterator>, + I2: Iterator>, { iter1: std::iter::Peekable, iter2: std::iter::Peekable, @@ -512,8 +573,8 @@ where impl MergingIterator where - I1: Iterator>, - I2: Iterator>, + I1: Iterator>, + I2: Iterator>, { fn new(iter1: I1, iter2: I2) -> Self { MergingIterator { @@ -525,8 +586,8 @@ where impl Iterator for MergingIterator where - I1: Iterator>, - I2: Iterator>, + I1: Iterator>, + I2: Iterator>, { type Item = Result<(BidKey, SpaceKey)>; diff --git a/node/src/sync.rs b/client/src/sync.rs similarity index 82% rename from node/src/sync.rs rename to client/src/sync.rs index d2fd4bf..8636051 100644 --- a/node/src/sync.rs +++ b/client/src/sync.rs @@ -2,14 +2,24 @@ use std::{net::SocketAddr, path::PathBuf, time::Duration}; use anyhow::{anyhow, Context}; use log::{info, warn}; -use protocol::{ +use spaces_protocol::{ bitcoin::{hashes::Hash, Block, BlockHash}, constants::ChainAnchor, hasher::BaseHash, }; use tokio::sync::broadcast; -use crate::{config::ExtendedNetwork, node::{BlockMeta, BlockSource, Node}, source::{BitcoinBlockSource, BitcoinRpc, BlockEvent, BlockFetchError, BlockFetcher}, std_wait, store::LiveStore}; -use crate::source::BitcoinRpcError; + +pub const ROOT_ANCHORS_COUNT: u32 = 120; + +use crate::{ + client::{BlockMeta, 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 { @@ -18,9 +28,9 @@ macro_rules! const_assert { } } -const COMMIT_BLOCK_INTERVAL: u32 = 36; +pub const COMMIT_BLOCK_INTERVAL: u32 = 36; const_assert!( - protocol::constants::ROLLOUT_BLOCK_INTERVAL % COMMIT_BLOCK_INTERVAL == 0, + spaces_protocol::constants::ROLLOUT_BLOCK_INTERVAL % COMMIT_BLOCK_INTERVAL == 0, "commit and rollout intervals must be aligned" ); @@ -33,6 +43,8 @@ pub struct Spaced { pub data_dir: PathBuf, pub bind: Vec, pub num_workers: usize, + pub anchors_path: Option, + pub synced: bool, } impl Spaced { @@ -102,9 +114,35 @@ impl Spaced { Ok(()) } + pub fn update_anchors(&self) -> anyhow::Result<()> { + 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 + ) + } + Ok(()) + } + pub fn handle_block( &mut self, - node: &mut Node, + node: &mut Client, id: ChainAnchor, block: Block, ) -> anyhow::Result<()> { @@ -132,6 +170,7 @@ impl Spaced { let tx = index.store.write().expect("write handle"); index.state.commit(state_meta, tx)?; } + self.update_anchors()?; } Ok(()) @@ -143,7 +182,7 @@ impl Spaced { shutdown: broadcast::Sender<()>, ) -> anyhow::Result<()> { let start_block: ChainAnchor = { self.chain.state.tip.read().expect("read").clone() }; - let mut node = Node::new(self.block_index_full); + let mut node = Client::new(self.block_index_full); info!( "Start block={} height={}", @@ -160,7 +199,16 @@ impl Spaced { } match receiver.try_recv() { Ok(event) => match event { - BlockEvent::Tip(_) => {} + BlockEvent::Tip(_) => { + self.synced = true; + if self + .anchors_path + .as_ref() + .is_some_and(|file| !file.exists()) + { + self.update_anchors()?; + } + } BlockEvent::Block(id, block) => { self.handle_block(&mut node, id, block)?; info!("block={} height={}", id.hash, id.height); diff --git a/node/src/wallets.rs b/client/src/wallets.rs similarity index 88% rename from node/src/wallets.rs rename to client/src/wallets.rs index d6c7191..e4b5f5e 100644 --- a/node/src/wallets.rs +++ b/client/src/wallets.rs @@ -1,33 +1,53 @@ -use std::{ - collections::BTreeMap, - str::FromStr, - time::{Duration}, -}; -use anyhow::{anyhow}; +use std::{collections::BTreeMap, str::FromStr, time::Duration}; + +use anyhow::anyhow; use clap::ValueEnum; use futures::{stream::FuturesUnordered, StreamExt}; use log::{info, warn}; -use protocol::{bitcoin::Txid, constants::ChainAnchor, hasher::{KeyHasher, SpaceKey}, script::SpaceScript, slabel::SLabel, FullSpaceOut, SpaceOut}; use serde::{Deserialize, Serialize}; use serde_json::json; +use spaces_protocol::{ + bitcoin::Txid, + constants::ChainAnchor, + hasher::{KeyHasher, SpaceKey}, + script::SpaceScript, + slabel::SLabel, + FullSpaceOut, SpaceOut, +}; +use spaces_wallet::{ + address::SpaceAddress, + bdk_wallet::{ + chain::{local_chain::CheckPoint, BlockId}, + KeychainKind, + }, + bitcoin, + bitcoin::{Address, Amount, FeeRate, OutPoint}, + builder::{CoinTransfer, SpaceTransfer, SpacesAwareCoinSelection}, + nostr::NostrEvent, + tx_event::{TxEvent, TxEventKind, TxRecord}, + Balance, DoubleUtxo, Listing, SpacesWallet, WalletInfo, WalletOutput, +}; use tabled::Tabled; use tokio::{ select, sync::{broadcast, mpsc, mpsc::Receiver, oneshot}, + time::Instant, +}; + +use crate::{ + checker::TxChecker, + client::BlockSource, + config::ExtendedNetwork, + rpc::{RpcWalletRequest, RpcWalletTxBuilder, WalletLoadRequest}, + source::{ + BitcoinBlockSource, BitcoinRpc, BitcoinRpcError, BlockEvent, BlockFetchError, BlockFetcher, + }, + std_wait, + store::{ChainState, LiveSnapshot, Sha256}, }; -use tokio::time::Instant; -use wallet::{address::SpaceAddress, bdk_wallet::{ - chain::{local_chain::CheckPoint, BlockId}, - KeychainKind, -}, bitcoin, bitcoin::{Address, Amount, FeeRate, OutPoint}, builder::{CoinTransfer, SpaceTransfer, SpacesAwareCoinSelection}, tx_event::{TxRecord, TxEvent, TxEventKind}, Balance, DoubleUtxo, Listing, SpacesWallet, WalletInfo, WalletOutput}; -use crate::{checker::TxChecker, config::ExtendedNetwork, node::BlockSource, rpc::{RpcWalletRequest, RpcWalletTxBuilder, WalletLoadRequest}, source::{ - BitcoinBlockSource, BitcoinRpc, BitcoinRpcError, BlockEvent, BlockFetchError, BlockFetcher, -}, std_wait, store::{ChainState, LiveSnapshot, Sha256}}; -use crate::rpc::SignedMessage; - -const MEMPOOL_CHECK_INTERVAL: Duration = Duration::from_secs( - if cfg!(debug_assertions) { 1 } else { 5 * 60 } -); + +const MEMPOOL_CHECK_INTERVAL: Duration = + Duration::from_secs(if cfg!(debug_assertions) { 1 } else { 5 * 60 }); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TxResponse { @@ -46,7 +66,6 @@ pub struct ListSpacesResponse { pub owned: Vec, } - #[derive(Tabled, Debug, Clone, Serialize, Deserialize)] #[tabled(rename_all = "UPPERCASE")] pub struct TxInfo { @@ -63,18 +82,25 @@ pub struct TxInfo { fn display_fee(fee: &Option) -> String { match fee { None => "--".to_string(), - Some(fee) => fee.to_string() + Some(fee) => fee.to_string(), } } fn display_events(events: &Vec) -> String { events .iter() - .map(|e| - format!("{} {}", - e.kind, - e.space.as_ref().map(|s| s.clone()).unwrap_or("".to_string()))) - .collect::>().join("\n") + .map(|e| { + format!( + "{} {}", + e.kind, + e.space + .as_ref() + .map(|s| s.clone()) + .unwrap_or("".to_string()) + ) + }) + .collect::>() + .join("\n") } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -134,10 +160,10 @@ pub enum WalletCommand { resp: crate::rpc::Responder>, }, UnloadWallet, - SignMessage { + SignEvent { space: String, - msg: protocol::Bytes, - resp: crate::rpc::Responder>, + event: NostrEvent, + resp: crate::rpc::Responder>, }, } @@ -154,7 +180,7 @@ pub struct RpcWallet { pub struct MempoolChecker<'a>(&'a BitcoinBlockSource); -impl wallet::Mempool for MempoolChecker<'_> { +impl spaces_wallet::Mempool for MempoolChecker<'_> { fn in_mempool(&self, txid: &Txid, height: u32) -> anyhow::Result { Ok(self.0.in_mempool(txid, height)?) } @@ -203,7 +229,13 @@ impl RpcWallet { let (_, fullspaceout) = SpacesWallet::verify_listing::(state, &listing)?; - let space = fullspaceout.spaceout.space.as_ref().expect("space").name.to_string(); + let space = fullspaceout + .spaceout + .space + .as_ref() + .expect("space") + .name + .to_string(); let previous_spaceout = fullspaceout.outpoint(); let tx = wallet.buy::(state, &listing, fee_rate)?; @@ -216,12 +248,15 @@ impl RpcWallet { let new_txid = tx.compute_txid(); let last_seen = source.rpc.broadcast_tx(&source.client, &tx)?; - let tx_record = TxRecord::new_with_events(tx, vec![TxEvent { - kind: TxEventKind::Buy, - space: Some(space), - previous_spaceout: Some(previous_spaceout), - details: None, - }]); + let tx_record = TxRecord::new_with_events( + tx, + vec![TxEvent { + kind: TxEventKind::Buy, + space: Some(space), + previous_spaceout: Some(previous_spaceout), + details: None, + }], + ); let events = tx_record.events.clone(); @@ -248,8 +283,7 @@ impl RpcWallet { ) -> anyhow::Result> { let unspendables = wallet.list_spaces_outpoints(state)?; let tx_events = wallet.get_tx_events(txid)?; - let builder = - wallet.build_fee_bump(unspendables, txid, fee_rate)?; + let builder = wallet.build_fee_bump(unspendables, txid, fee_rate)?; let psbt = builder.finish()?; let replacement = wallet.sign(psbt, None)?; @@ -397,32 +431,37 @@ impl RpcWallet { WalletCommand::UnloadWallet => { info!("Unloading wallet '{}' ...", wallet.name()); } - WalletCommand::Buy { listing, resp, skip_tx_check, fee_rate } => { - _ = resp.send(Self::handle_buy(source, state, wallet, listing, skip_tx_check, fee_rate)); + WalletCommand::Buy { + listing, + resp, + skip_tx_check, + fee_rate, + } => { + _ = resp.send(Self::handle_buy( + source, + state, + wallet, + listing, + skip_tx_check, + fee_rate, + )); } WalletCommand::Sell { space, price, resp } => { _ = resp.send(wallet.sell::(state, &space, Amount::from_sat(price))); } - WalletCommand::SignMessage { space, msg, resp } => { - match wallet.sign_message::(state, &space, msg.as_slice()) { - Ok(signature) => { - _ = resp.send(Ok(SignedMessage { - space, - message: msg, - signature, - })); - } - Err(err) => { - _ = resp.send(Err(err)); - } - } + WalletCommand::SignEvent { space, event, resp } => { + _ = resp.send(wallet.sign_event::(state, &space, event)); } } Ok(()) } /// Returns true if Bitcoin, protocol, and wallet tips match. - fn all_synced(bitcoin: &BitcoinBlockSource, protocol: &mut LiveSnapshot, wallet: &SpacesWallet) -> Option { + fn all_synced( + bitcoin: &BitcoinBlockSource, + protocol: &mut LiveSnapshot, + wallet: &SpacesWallet, + ) -> Option { let bitcoin_tip = match bitcoin.get_best_chain() { Ok(tip) => tip, Err(e) => { @@ -475,7 +514,14 @@ impl RpcWallet { } if let Ok(command) = commands.try_recv() { let synced = Self::all_synced(&source, &mut state, &wallet).is_some(); - Self::wallet_handle_commands(network, &source, &mut state, &mut wallet, command, synced)?; + Self::wallet_handle_commands( + network, + &source, + &mut state, + &mut wallet, + command, + synced, + )?; } if let Ok(event) = receiver.try_recv() { match event { @@ -578,11 +624,14 @@ impl RpcWallet { if let Some(common_tip) = Self::all_synced(&source, &mut state, &wallet) { let mem = MempoolChecker(&source); match wallet.update_unconfirmed_bids(mem, common_tip.height, &mut state) { - Ok(txids) => for txid in txids { - info!("Dropped {} - no longer in the mempool", txid); + Ok(txids) => { + for txid in txids { + info!("Dropped {} - no longer in the mempool", txid); + } + } + Err(err) => { + warn!("Could not check for unconfirmed bids in mempool: {}", err) } - Err(err) => - warn!("Could not check for unconfirmed bids in mempool: {}", err), } last_mempool_check = Instant::now(); } @@ -608,9 +657,11 @@ impl RpcWallet { owned: vec![], }; for (txid, event) in recent_events { - if unspent.iter() - .any(|out| out.space.as_ref() - .is_some_and(|s| &s.name.to_string() == event.space.as_ref().unwrap())) { + if unspent.iter().any(|out| { + out.space + .as_ref() + .is_some_and(|s| &s.name.to_string() == event.space.as_ref().unwrap()) + }) { continue; } let name = SLabel::from_str(event.space.as_ref().unwrap()).expect("valid space name"); @@ -623,7 +674,10 @@ impl RpcWallet { continue; } - if event.previous_spaceout.is_some_and(|input| input == space.outpoint()) { + if event + .previous_spaceout + .is_some_and(|input| input == space.outpoint()) + { continue; } res.outbid.push(space); @@ -642,14 +696,13 @@ impl RpcWallet { }; let space = entry.spaceout.space.as_ref().expect("space"); - if matches!(space.covenant, protocol::Covenant::Bid { .. }) { + if matches!(space.covenant, spaces_protocol::Covenant::Bid { .. }) { res.winning.push(entry); - } else if matches!(space.covenant, protocol::Covenant::Transfer { .. }) { + } else if matches!(space.covenant, spaces_protocol::Covenant::Transfer { .. }) { res.owned.push(entry); } } - Ok(res) } @@ -690,9 +743,7 @@ impl RpcWallet { let mut events = TxEvent::all(&conn, tx.txid).expect("tx event"); for event in events.iter_mut() { match event.kind { - TxEventKind::Commit => { - event.details = None - } + TxEventKind::Commit => event.details = None, _ => {} } } @@ -781,7 +832,7 @@ impl RpcWallet { }; info!("Using fee rate: {} sat/vB", fee_rate.to_sat_per_vb_ceil()); - let mut builder = wallet::builder::Builder::new(); + let mut builder = spaces_wallet::builder::Builder::new(); builder = builder.fee_rate(fee_rate); if tx.bidouts.is_some() { @@ -795,9 +846,7 @@ impl RpcWallet { match req { RpcWalletRequest::SendCoins(params) => { let recipient = match Self::resolve(network, store, ¶ms.to, false)? { - None => { - return Err(anyhow!("send: could not resolve '{}'", params.to)) - } + None => return Err(anyhow!("send: could not resolve '{}'", params.to)), Some(r) => r, }; builder = builder.add_send(CoinTransfer { @@ -817,9 +866,7 @@ impl RpcWallet { let recipient = if let Some(to) = params.to { match Self::resolve(network, store, &to, true)? { - None => { - return Err(anyhow!("transfer: could not resolve '{}'", to)) - } + None => return Err(anyhow!("transfer: could not resolve '{}'", to)), Some(r) => Some(r), } } else { @@ -831,32 +878,30 @@ impl RpcWallet { match store.get_space_info(&spacehash)? { None => return Err(anyhow!("transfer: you don't own `{}`", space)), Some(full) - if full.spaceout.space.is_none() - || !full.spaceout.space.as_ref().unwrap().is_owned() - || !wallet - .is_mine(full.spaceout.script_pubkey.clone()) => - { - return Err(anyhow!("transfer: you don't own `{}`", space)); - } + if full.spaceout.space.is_none() + || !full.spaceout.space.as_ref().unwrap().is_owned() + || !wallet.is_mine(full.spaceout.script_pubkey.clone()) => + { + return Err(anyhow!("transfer: you don't own `{}`", space)); + } Some(full) if wallet.get_utxo(full.outpoint()).is_none() => { return Err(anyhow!( - "transfer '{}': wallet already has a pending tx for this space", - space - )); + "transfer '{}': wallet already has a pending tx for this space", + space + )); } Some(full) => { let recipient = match recipient.clone() { - None => { - SpaceAddress( - Address::from_script( - full.spaceout.script_pubkey.as_script(), - wallet.config.network, - ).expect("valid script") + None => SpaceAddress( + Address::from_script( + full.spaceout.script_pubkey.as_script(), + wallet.config.network, ) - } - Some(addr) => SpaceAddress(addr) + .expect("valid script"), + ), + Some(addr) => SpaceAddress(addr), }; builder = builder.add_transfer(SpaceTransfer { @@ -964,10 +1009,7 @@ impl RpcWallet { } let spaceout = spaceout.unwrap(); if !wallet.is_mine(spaceout.spaceout.script_pubkey.clone()) { - return Err(anyhow!( - "script '{}': you don't own this space", - space - )); + return Err(anyhow!("script '{}': you don't own this space", space)); } if wallet.get_utxo(spaceout.outpoint()).is_none() { @@ -1007,13 +1049,17 @@ impl RpcWallet { } } - let mut tx_iter = builder.build_iter(tx.dust, median_time, wallet, unspendables, bid_replacement)?; + let mut tx_iter = + builder.build_iter(tx.dust, median_time, wallet, unspendables, bid_replacement)?; let mut result_set = Vec::new(); while let Some(tx_result) = tx_iter.next() { let tx_record = tx_result?; - let is_bid = tx_record.events.iter().any(|tag| tag.kind == TxEventKind::Bid); + let is_bid = tx_record + .events + .iter() + .any(|tag| tag.kind == TxEventKind::Bid); result_set.push(TxResponse { txid: tx_record.tx.compute_txid(), events: tx_record.events.clone(), @@ -1029,7 +1075,9 @@ impl RpcWallet { let result = source.rpc.broadcast_tx(&source.client, &tx_record.tx); match result { Ok(last_seen) => { - tx_iter.wallet.apply_unconfirmed_tx_record(tx_record, last_seen)?; + tx_iter + .wallet + .apply_unconfirmed_tx_record(tx_record, last_seen)?; tx_iter.wallet.commit()?; } Err(e) => { @@ -1218,32 +1266,24 @@ impl RpcWallet { resp_rx.await? } - pub async fn send_sell( - &self, - space: String, - price: u64, - ) -> anyhow::Result { + pub async fn send_sell(&self, space: String, price: u64) -> anyhow::Result { let (resp, resp_rx) = oneshot::channel(); self.sender - .send(WalletCommand::Sell { - space, - resp, - price, - }) + .send(WalletCommand::Sell { space, resp, price }) .await?; resp_rx.await? } - pub async fn send_sign_message( + pub async fn send_sign_event( &self, space: &str, - msg: protocol::Bytes, - ) -> anyhow::Result { + event: NostrEvent, + ) -> anyhow::Result { let (resp, resp_rx) = oneshot::channel(); self.sender - .send(WalletCommand::SignMessage { + .send(WalletCommand::SignEvent { space: space.to_string(), - msg, + event, resp, }) .await?; @@ -1262,7 +1302,6 @@ impl RpcWallet { resp_rx.await? } - pub async fn send_force_spend( &self, outpoint: OutPoint, diff --git a/node/tests/fetcher_tests.rs b/client/tests/fetcher_tests.rs similarity index 89% rename from node/tests/fetcher_tests.rs rename to client/tests/fetcher_tests.rs index fbb041f..83cf911 100644 --- a/node/tests/fetcher_tests.rs +++ b/client/tests/fetcher_tests.rs @@ -4,9 +4,11 @@ use std::{ }; use anyhow::Result; -use protocol::{bitcoin::BlockHash, constants::ChainAnchor}; -use spaced::source::{BitcoinBlockSource, BitcoinRpc, BitcoinRpcAuth, BlockEvent, BlockFetcher}; -use testutil::TestRig; +use spaces_client::source::{ + BitcoinBlockSource, BitcoinRpc, BitcoinRpcAuth, BlockEvent, BlockFetcher, +}; +use spaces_protocol::{bitcoin::BlockHash, constants::ChainAnchor}; +use spaces_testutil::TestRig; async fn setup(blocks: u64) -> Result<(TestRig, u64, BlockHash)> { let rig = TestRig::new().await?; diff --git a/node/tests/integration_tests.rs b/client/tests/integration_tests.rs similarity index 73% rename from node/tests/integration_tests.rs rename to client/tests/integration_tests.rs index b92d87a..c8e3d74 100644 --- a/node/tests/integration_tests.rs +++ b/client/tests/integration_tests.rs @@ -1,15 +1,20 @@ use std::{path::PathBuf, str::FromStr}; -use protocol::{bitcoin::{Amount, FeeRate}, constants::RENEWAL_INTERVAL, script::SpaceScript, Bytes, Covenant}; -use spaced::{ + +use spaces_client::{ rpc::{ BidParams, ExecuteParams, OpenParams, RegisterParams, RpcClient, RpcWalletRequest, RpcWalletTxBuilder, TransferSpacesParams, }, wallets::{AddressKind, WalletResponse}, }; -use testutil::TestRig; -use wallet::export::WalletExport; -use wallet::tx_event::TxEventKind; +use spaces_protocol::{ + bitcoin::{Amount, FeeRate}, + constants::RENEWAL_INTERVAL, + script::SpaceScript, + Covenant, +}; +use spaces_testutil::TestRig; +use spaces_wallet::{export::WalletExport, nostr::NostrEvent, tx_event::TxEventKind}; const ALICE: &str = "wallet_99"; const BOB: &str = "wallet_98"; @@ -30,8 +35,8 @@ async fn it_should_open_a_space_for_auction(rig: &TestRig) -> anyhow::Result<()> })], false, ) - .await - .expect("send request"); + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&response).unwrap()); @@ -87,8 +92,8 @@ async fn it_should_allow_outbidding(rig: &TestRig) -> anyhow::Result<()> { })], false, ) - .await - .expect("send request"); + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&result).unwrap()); rig.mine_blocks(1, None).await?; @@ -150,16 +155,20 @@ async fn it_should_allow_outbidding(rig: &TestRig) -> anyhow::Result<()> { Ok(()) } - async fn it_should_insert_txout_for_bids(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_synced().await?; rig.wait_until_wallet_synced(BOB).await?; - let tx = rig.spaced.client - .wallet_list_transactions(BOB, 10, 0).await?.iter() + let tx = rig + .spaced + .client + .wallet_list_transactions(BOB, 10, 0) + .await? + .iter() .filter(|tx| tx.events.iter().any(|event| event.kind == TxEventKind::Bid)) .next() - .expect("a bid").clone(); + .expect("a bid") + .clone(); assert!(tx.fee.is_some(), "must be able to calculate fees"); Ok(()) @@ -195,11 +204,11 @@ async fn it_should_only_accept_forced_zero_value_bid_increments_and_revoke( vec![RpcWalletRequest::Bid(BidParams { name: TEST_SPACE.to_string(), amount: last_bid.to_sat(), - }), ], + }),], false ) - .await - .is_err(), + .await + .is_err(), "shouldn't be able to bid with same value unless forced" ); @@ -214,7 +223,7 @@ async fn it_should_only_accept_forced_zero_value_bid_increments_and_revoke( requests: vec![RpcWalletRequest::Bid(BidParams { name: TEST_SPACE.to_string(), amount: last_bid.to_sat(), - }), ], + }),], fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), dust: None, force: true, @@ -314,8 +323,8 @@ async fn it_should_allow_claim_on_or_after_claim_height(rig: &TestRig) -> anyhow })], false, ) - .await - .expect("send request"); + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&result).unwrap()); rig.mine_blocks(1, None).await?; @@ -324,7 +333,11 @@ async fn it_should_allow_claim_on_or_after_claim_height(rig: &TestRig) -> anyhow rig.wait_until_wallet_synced(wallet).await?; let all_spaces_2 = rig.spaced.client.wallet_list_spaces(wallet).await?; - assert_eq!(all_spaces.owned.len() + 1, all_spaces_2.owned.len(), "must be equal"); + assert_eq!( + all_spaces.owned.len() + 1, + all_spaces_2.owned.len(), + "must be equal" + ); let space = rig .spaced @@ -368,8 +381,8 @@ async fn it_should_allow_batch_transfers_refreshing_expire_height( })], false, ) - .await - .expect("send request"); + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&result).unwrap()); @@ -380,7 +393,11 @@ async fn it_should_allow_batch_transfers_refreshing_expire_height( rig.wait_until_wallet_synced(ALICE).await?; let all_spaces_2 = rig.spaced.client.wallet_list_spaces(ALICE).await?; - assert_eq!(all_spaces.owned.len(), all_spaces_2.owned.len(), "must be equal"); + assert_eq!( + all_spaces.owned.len(), + all_spaces_2.owned.len(), + "must be equal" + ); let _ = all_spaces_2.owned.iter().for_each(|s| { let space = s.spaceout.space.as_ref().expect("space"); @@ -408,8 +425,11 @@ async fn it_should_allow_applying_script_in_batch(rig: &TestRig) -> anyhow::Resu rig.wait_until_wallet_synced(ALICE).await?; rig.wait_until_synced().await?; let all_spaces = rig.spaced.client.wallet_list_spaces(ALICE).await?; - let registered_spaces: Vec<_> = all_spaces.owned.iter().map(|out| - out.spaceout.space.as_ref().expect("space").name.to_string()).collect(); + let registered_spaces: Vec<_> = all_spaces + .owned + .iter() + .map(|out| out.spaceout.space.as_ref().expect("space").name.to_string()) + .collect(); let result = wallet_do( rig, @@ -423,11 +443,12 @@ async fn it_should_allow_applying_script_in_batch(rig: &TestRig) -> anyhow::Resu RpcWalletRequest::Execute(ExecuteParams { context: registered_spaces.clone(), space_script: SpaceScript::create_set_fallback(&[0xDE, 0xAD, 0xBE, 0xEF]), - })], + }), + ], false, ) - .await - .expect("send request"); + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&result).unwrap()); @@ -438,8 +459,16 @@ async fn it_should_allow_applying_script_in_batch(rig: &TestRig) -> anyhow::Resu rig.wait_until_wallet_synced(ALICE).await?; let all_spaces_2 = rig.spaced.client.wallet_list_spaces(ALICE).await?; - assert_eq!(all_spaces.owned.len(), all_spaces_2.owned.len(), "must be equal"); - assert_eq!(all_spaces.winning.len(), all_spaces_2.winning.len(), "must be equal"); + assert_eq!( + all_spaces.owned.len(), + all_spaces_2.owned.len(), + "must be equal" + ); + assert_eq!( + all_spaces.winning.len(), + all_spaces_2.winning.len(), + "must be equal" + ); all_spaces_2.owned.iter().for_each(|s| { let space = s.spaceout.space.as_ref().expect("space"); @@ -474,8 +503,13 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_wallet_synced(EVE).await?; // make sure Bob runs out of confirmed bidouts - let bob_bidout_count = rig.spaced.client.wallet_list_bidouts(BOB) - .await.expect("get bidouts").len(); + let bob_bidout_count = rig + .spaced + .client + .wallet_list_bidouts(BOB) + .await + .expect("get bidouts") + .len(); for i in 0..bob_bidout_count { wallet_do( rig, @@ -486,7 +520,8 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { })], false, ) - .await.expect("bob makes a bid"); + .await + .expect("bob makes a bid"); } // create some confirmed bid outs for Alice and Eve @@ -504,7 +539,8 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { skip_tx_check: false, }, ) - .await.expect("send request"); + .await + .expect("send request"); rig.spaced .client .wallet_send_request( @@ -519,7 +555,8 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { skip_tx_check: false, }, ) - .await.expect("send request"); + .await + .expect("send request"); rig.mine_blocks(1, None).await?; rig.wait_until_synced().await?; @@ -536,7 +573,8 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { })], false, ) - .await.expect("send request"); + .await + .expect("send request"); let response = serde_json::to_string_pretty(&response).unwrap(); println!("Alice bid on @test2 (unconf): {}", response); @@ -551,7 +589,8 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { })], false, ) - .await.expect("send request"); + .await + .expect("send request"); let response = serde_json::to_string_pretty(&response).unwrap(); @@ -580,8 +619,8 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { skip_tx_check: false, }, ) - .await.expect("send request"); - + .await + .expect("send request"); let response = serde_json::to_string_pretty(&replacement).unwrap(); println!("{}", response); @@ -614,15 +653,21 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { skip_tx_check: false, }, ) - .await.expect("send request"); + .await + .expect("send request"); - let eve_replacement_txid = replacement.result.iter().filter_map(|tx| { - if tx.events.iter().any(|event| event.kind == TxEventKind::Bid) { - Some(tx.txid) - } else { - None - } - }).next().expect("should have eve replacement txid"); + let eve_replacement_txid = replacement + .result + .iter() + .filter_map(|tx| { + if tx.events.iter().any(|event| event.kind == TxEventKind::Bid) { + Some(tx.txid) + } else { + None + } + }) + .next() + .expect("should have eve replacement txid"); let response = serde_json::to_string_pretty(&replacement).unwrap(); println!("Eve's replacement: {}", response); @@ -642,7 +687,8 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { .spaced .client .wallet_list_transactions(ALICE, 1000, 0) - .await.expect("list transactions"); + .await + .expect("list transactions"); let unconfirmed: Vec<_> = txs.iter().filter(|tx| !tx.confirmed).collect(); for tx in &unconfirmed { println!("Alice's unconfiremd: {}", tx.txid); @@ -661,7 +707,8 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { .spaced .client .wallet_list_transactions(ALICE, 1000, 0) - .await.expect("list transactions"); + .await + .expect("list transactions"); assert!( txs.iter().all(|tx| tx.txid != eve_replacement_txid), @@ -673,14 +720,22 @@ async fn it_should_replace_mempool_bids(rig: &TestRig) -> anyhow::Result<()> { .spaced .client .wallet_list_transactions(EVE, 1000, 0) - .await.expect("list transactions"); + .await + .expect("list transactions"); assert!( - eve_txs.iter().any(|tx| tx.txid == eve_replacement_txid && tx.confirmed), + eve_txs + .iter() + .any(|tx| tx.txid == eve_replacement_txid && tx.confirmed), "Eve's tx should be confirmed" ); - let space = rig.spaced.client.get_space("@test2").await.expect("space") + let space = rig + .spaced + .client + .get_space("@test2") + .await + .expect("space") .expect("space exists"); println!("Space: {}", serde_json::to_string_pretty(&space).unwrap()); @@ -707,7 +762,10 @@ async fn it_should_maintain_locktime_when_fee_bumping(rig: &TestRig) -> anyhow:: ) .await?; - println!("bumping fee: {}", serde_json::to_string_pretty(&response).unwrap()); + println!( + "bumping fee: {}", + serde_json::to_string_pretty(&response).unwrap() + ); let txid = response.result[0].txid; for tx_res in response.result { @@ -727,7 +785,10 @@ async fn it_should_maintain_locktime_when_fee_bumping(rig: &TestRig) -> anyhow:: ) .await?; - println!("after fee bump: {}", serde_json::to_string_pretty(&bump).unwrap()); + println!( + "after fee bump: {}", + serde_json::to_string_pretty(&bump).unwrap() + ); assert_eq!(bump.len(), 1, "should only be 1 tx"); assert!(bump[0].error.is_none(), "should be no errors"); @@ -740,7 +801,9 @@ async fn it_should_maintain_locktime_when_fee_bumping(rig: &TestRig) -> anyhow:: Ok(()) } -async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times(rig: &TestRig) -> anyhow::Result<()> { +async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times( + rig: &TestRig, +) -> anyhow::Result<()> { rig.wait_until_wallet_synced(BOB).await.expect("synced"); rig.wait_until_wallet_synced(ALICE).await.expect("synced"); @@ -754,19 +817,25 @@ async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times(r to: None, })], false, - ).await.expect("send request"); + ) + .await + .expect("send request"); println!("{}", serde_json::to_string_pretty(&response).unwrap()); - assert!(wallet_do( - rig, - BOB, - vec![RpcWalletRequest::Register(RegisterParams { - name: awaiting_claim.clone(), - to: None, - })], - false, - ).await.is_err(), "should not allow register to same space multiple times"); - + assert!( + wallet_do( + rig, + BOB, + vec![RpcWalletRequest::Register(RegisterParams { + name: awaiting_claim.clone(), + to: None, + })], + false, + ) + .await + .is_err(), + "should not allow register to same space multiple times" + ); // Try transfer multiple times let bob_address = rig @@ -784,9 +853,15 @@ async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times(r to: Some(bob_address.clone()), })], false, - ).await.expect("send request"); + ) + .await + .expect("send request"); - println!("Transfer {}: {}", transfer, serde_json::to_string_pretty(&response).unwrap()); + println!( + "Transfer {}: {}", + transfer, + serde_json::to_string_pretty(&response).unwrap() + ); wallet_do( rig, ALICE, @@ -795,7 +870,9 @@ async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times(r to: Some(bob_address), })], false, - ).await.expect_err("there's already a transfer submitted"); + ) + .await + .expect_err("there's already a transfer submitted"); let setdata = "@test9996".to_string(); let response = wallet_do( @@ -806,9 +883,14 @@ async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times(r space_script: SpaceScript::create_set_fallback(&[0xAA, 0xAA]), })], false, - ).await.expect("send request"); + ) + .await + .expect("send request"); - println!("Update sent {}", serde_json::to_string_pretty(&response).unwrap()); + println!( + "Update sent {}", + serde_json::to_string_pretty(&response).unwrap() + ); wallet_do( rig, ALICE, @@ -817,19 +899,32 @@ async fn it_should_not_allow_register_or_transfer_to_same_space_multiple_times(r space_script: SpaceScript::create_set_fallback(&[0xDE, 0xAD]), })], false, - ).await.expect_err("there's already an update submitted"); + ) + .await + .expect_err("there's already an update submitted"); rig.mine_blocks(1, None).await.expect("mine"); rig.wait_until_synced().await.expect("synced"); - let space = rig.spaced.client.get_space("@test9996") - .await.expect("space").expect("spaceout exists") - .spaceout.space.expect("space exists"); + let space = rig + .spaced + .client + .get_space("@test9996") + .await + .expect("space") + .expect("spaceout exists") + .spaceout + .space + .expect("space exists"); match space.covenant { Covenant::Transfer { data, .. } => { assert!(data.is_some(), "data must be set"); - assert_eq!(data.unwrap().as_slice(), [0xAAu8, 0xAA].as_slice(), "data not correct"); + assert_eq!( + data.unwrap().as_slice(), + [0xAAu8, 0xAA].as_slice(), + "data not correct" + ); } _ => panic!("expected transfer covenant"), } @@ -867,58 +962,120 @@ async fn it_can_batch_txs(rig: &TestRig) -> anyhow::Result<()> { context: vec![ "@test10000".to_string(), "@test9999".to_string(), - "@test9998".to_string() + "@test9998".to_string(), ], // space_script: SpaceScript::create_set_fallback(&[0xEE, 0xEE, 0x22, 0x22]), space_script: SpaceScript::create_set_fallback(&[0xEE, 0xEE, 0x22, 0x22]), }), ], false, - ).await.expect("send request"); + ) + .await + .expect("send request"); - println!("batch request: {}", serde_json::to_string_pretty(&res).unwrap()); - assert!(res.result.iter().all(|tx| tx.error.is_none()), "batching should work"); + println!( + "batch request: {}", + serde_json::to_string_pretty(&res).unwrap() + ); + assert!( + res.result.iter().all(|tx| tx.error.is_none()), + "batching should work" + ); assert_eq!(res.result.len(), 5, "expected 4 transactions"); rig.mine_blocks(1, None).await.expect("mine"); rig.wait_until_wallet_synced(ALICE).await.expect("synced"); rig.wait_until_wallet_synced(BOB).await.expect("synced"); - let bob_spaces = rig.spaced.client.wallet_list_spaces(BOB).await.expect("bob spaces"); - assert!(bob_spaces.owned.iter().find(|output| - output.spaceout.space.as_ref().is_some_and(|s| s.name.to_string() == "@test9996")).is_some(), - "expected bob to own the space name" + let bob_spaces = rig + .spaced + .client + .wallet_list_spaces(BOB) + .await + .expect("bob spaces"); + assert!( + bob_spaces + .owned + .iter() + .find(|output| output + .spaceout + .space + .as_ref() + .is_some_and(|s| s.name.to_string() == "@test9996")) + .is_some(), + "expected bob to own the space name" ); - let alice_spaces = rig.spaced.client.wallet_list_spaces(ALICE).await.expect("alice spaces"); - let batch1 = alice_spaces.winning.iter().find(|output| - output.spaceout.space.as_ref().is_some_and(|s| s.name.to_string() == "@batch1")) - .expect("exists").spaceout.space.clone().expect("space exists"); + let alice_spaces = rig + .spaced + .client + .wallet_list_spaces(ALICE) + .await + .expect("alice spaces"); + let batch1 = alice_spaces + .winning + .iter() + .find(|output| { + output + .spaceout + .space + .as_ref() + .is_some_and(|s| s.name.to_string() == "@batch1") + }) + .expect("exists") + .spaceout + .space + .clone() + .expect("space exists"); match batch1.covenant { Covenant::Bid { total_burned, .. } => { assert_eq!(total_burned.to_sat(), 1000, "incorrect burn value") } - _ => panic!("must be a bid") + _ => panic!("must be a bid"), } - let batch2 = alice_spaces.winning.iter().find(|output| - output.spaceout.space.as_ref().is_some_and(|s| s.name.to_string() == "@batch2")) - .expect("exists").spaceout.space.clone().expect("space exists"); + let batch2 = alice_spaces + .winning + .iter() + .find(|output| { + output + .spaceout + .space + .as_ref() + .is_some_and(|s| s.name.to_string() == "@batch2") + }) + .expect("exists") + .spaceout + .space + .clone() + .expect("space exists"); match batch2.covenant { Covenant::Bid { total_burned, .. } => { assert_eq!(total_burned.to_sat(), 1000, "incorrect burn value") } - _ => panic!("must be a bid") + _ => panic!("must be a bid"), } for space in vec![ "@test10000".to_string(), "@test9999".to_string(), - "@test9998".to_string() + "@test9998".to_string(), ] { - let space = alice_spaces.owned.iter().find(|output| - output.spaceout.space.as_ref().is_some_and(|s| s.name.to_string() == space)) - .expect("exists").spaceout.space.clone().expect("space exists"); + let space = alice_spaces + .owned + .iter() + .find(|output| { + output + .spaceout + .space + .as_ref() + .is_some_and(|s| s.name.to_string() == space) + }) + .expect("exists") + .spaceout + .space + .clone() + .expect("space exists"); match space.covenant { Covenant::Transfer { data, .. } => { @@ -928,20 +1085,19 @@ async fn it_can_batch_txs(rig: &TestRig) -> anyhow::Result<()> { "must set correct data" ); } - _ => panic!("must be a transfer") + _ => panic!("must be a transfer"), } } Ok(()) } - async fn it_can_use_reserved_op_codes(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_wallet_synced(ALICE).await.expect("synced"); let alice_spaces = vec![ "@test10000".to_string(), "@test9999".to_string(), - "@test9998".to_string() + "@test9998".to_string(), ]; let res = rig @@ -951,12 +1107,10 @@ async fn it_can_use_reserved_op_codes(rig: &TestRig) -> anyhow::Result<()> { ALICE, RpcWalletTxBuilder { bidouts: None, - requests: vec![ - RpcWalletRequest::Execute(ExecuteParams { - context: alice_spaces.clone(), - space_script: SpaceScript::create_reserve() - }), - ], + requests: vec![RpcWalletRequest::Execute(ExecuteParams { + context: alice_spaces.clone(), + space_script: SpaceScript::create_reserve(), + })], fee_rate: Some(FeeRate::from_sat_per_vb(1).expect("fee")), dust: None, force: true, @@ -964,20 +1118,34 @@ async fn it_can_use_reserved_op_codes(rig: &TestRig) -> anyhow::Result<()> { skip_tx_check: true, }, ) - .await.expect("response"); + .await + .expect("response"); - assert!(res.result.iter().all(|tx| tx.error.is_none()), "reserve should work"); + assert!( + res.result.iter().all(|tx| tx.error.is_none()), + "reserve should work" + ); assert_eq!(res.result.len(), 2, "expected 2 transactions"); rig.mine_blocks(1, None).await.expect("mine"); rig.wait_until_wallet_synced(ALICE).await.expect("synced"); for space in alice_spaces { - let space = rig.spaced.client.get_space(&space) - .await.expect("space").expect("space exists") - .spaceout.space.expect("space exists"); + let space = rig + .spaced + .client + .get_space(&space) + .await + .expect("space") + .expect("space exists") + .spaceout + .space + .expect("space exists"); - assert!(matches!(space.covenant, Covenant::Reserved), "expected a reserved space"); + assert!( + matches!(space.covenant, Covenant::Reserved), + "expected a reserved space" + ); } Ok(()) @@ -987,23 +1155,53 @@ async fn it_should_allow_buy_sell(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_wallet_synced(ALICE).await.expect("synced"); rig.wait_until_wallet_synced(BOB).await.expect("synced"); - let alice_spaces = rig.spaced.client.wallet_list_spaces(ALICE).await.expect("alice spaces"); - let space = alice_spaces.owned.first().expect("alice should have at least 1 space"); + let alice_spaces = rig + .spaced + .client + .wallet_list_spaces(ALICE) + .await + .expect("alice spaces"); + let space = alice_spaces + .owned + .first() + .expect("alice should have at least 1 space"); let space_name = space.spaceout.space.as_ref().unwrap().name.to_string(); - let listing = rig.spaced.client.wallet_sell(ALICE, space_name.clone(), 5000).await.expect("sell"); + let listing = rig + .spaced + .client + .wallet_sell(ALICE, space_name.clone(), 5000) + .await + .expect("sell"); - println!("listing\n{}", serde_json::to_string_pretty(&listing).unwrap()); + println!( + "listing\n{}", + serde_json::to_string_pretty(&listing).unwrap() + ); - rig.spaced.client.verify_listing(listing.clone()).await.expect("verify"); + rig.spaced + .client + .verify_listing(listing.clone()) + .await + .expect("verify"); - let alice_balance = rig.spaced.client.wallet_get_balance(ALICE).await.expect("balance"); - let buy = rig.spaced.client.wallet_buy( - BOB, - listing.clone(), - Some(FeeRate::from_sat_per_vb(1).expect("rate")), - false).await.expect("buy" - ); + let alice_balance = rig + .spaced + .client + .wallet_get_balance(ALICE) + .await + .expect("balance"); + let buy = rig + .spaced + .client + .wallet_buy( + BOB, + listing.clone(), + Some(FeeRate::from_sat_per_vb(1).expect("rate")), + false, + ) + .await + .expect("buy"); println!("{}", serde_json::to_string_pretty(&buy).unwrap()); @@ -1012,15 +1210,38 @@ async fn it_should_allow_buy_sell(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_wallet_synced(BOB).await.expect("synced"); rig.wait_until_wallet_synced(ALICE).await.expect("synced"); - rig.spaced.client.verify_listing(listing) - .await.expect_err("should no longer be valid"); + rig.spaced + .client + .verify_listing(listing) + .await + .expect_err("should no longer be valid"); - let bob_spaces = rig.spaced.client.wallet_list_spaces(BOB).await.expect("bob spaces"); + let bob_spaces = rig + .spaced + .client + .wallet_list_spaces(BOB) + .await + .expect("bob spaces"); - assert!(bob_spaces.owned.iter().find(|s| s.spaceout.space.as_ref().unwrap().name.to_string() == space_name).is_some(), "bob should own it now"); + assert!( + bob_spaces + .owned + .iter() + .find(|s| s.spaceout.space.as_ref().unwrap().name.to_string() == space_name) + .is_some(), + "bob should own it now" + ); - let alice_balance_after = rig.spaced.client.wallet_get_balance(ALICE).await.expect("balance"); - assert_eq!(alice_balance.balance + Amount::from_sat(5666), alice_balance_after.balance); + let alice_balance_after = rig + .spaced + .client + .wallet_get_balance(ALICE) + .await + .expect("balance"); + assert_eq!( + alice_balance.balance + Amount::from_sat(5666), + alice_balance_after.balance + ); Ok(()) } @@ -1028,27 +1249,35 @@ async fn it_should_allow_buy_sell(rig: &TestRig) -> anyhow::Result<()> { async fn it_should_allow_sign_verify_messages(rig: &TestRig) -> anyhow::Result<()> { rig.wait_until_wallet_synced(BOB).await.expect("synced"); - let alice_spaces = rig.spaced.client.wallet_list_spaces(BOB).await.expect("bob spaces"); - let space = alice_spaces.owned.first().expect("bob should have at least 1 space"); + let alice_spaces = rig + .spaced + .client + .wallet_list_spaces(BOB) + .await + .expect("bob spaces"); + let space = alice_spaces + .owned + .first() + .expect("bob should have at least 1 space"); let space_name = space.spaceout.space.as_ref().unwrap().name.to_string(); - let msg = Bytes::new(b"hello world".to_vec()); - let signed = rig.spaced.client.wallet_sign_message(BOB, &space_name, msg.clone()).await.expect("sign"); + let msg = NostrEvent::new(1, "hello world", vec![]); + let signed = rig + .spaced + .client + .wallet_sign_event(BOB, &space_name, msg.clone()) + .await + .expect("sign"); println!("signed\n{}", serde_json::to_string_pretty(&signed).unwrap()); - assert_eq!(signed.space, space_name, "bad signer"); - assert_eq!(signed.message.as_slice(), msg.as_slice(), "msg content must match"); + assert_eq!(signed.content, msg.content, "msg content must match"); - rig.spaced.client.verify_message(signed.clone()).await.expect("verify"); - - let mut bad_signer = signed.clone(); - bad_signer.space = "@nothanks".to_string(); - rig.spaced.client.verify_message(bad_signer).await.expect_err("bad signer"); - - let mut bad_msg = signed.clone(); - bad_msg.message = Bytes::new(b"hello world 2".to_vec()); - rig.spaced.client.verify_message(bad_msg).await.expect_err("bad msg"); + rig.spaced + .client + .verify_event(&space_name, signed.clone()) + .await + .expect("verify"); Ok(()) } @@ -1081,24 +1310,51 @@ async fn run_auction_tests() -> anyhow::Result<()> { load_wallet(&rig, wallets_path.clone(), BOB).await?; load_wallet(&rig, wallets_path, EVE).await?; - it_should_open_a_space_for_auction(&rig).await.expect("should open auction"); - it_should_allow_outbidding(&rig).await.expect("should allow outbidding"); - it_should_insert_txout_for_bids(&rig).await.expect("should insert txout"); - it_should_only_accept_forced_zero_value_bid_increments_and_revoke(&rig).await.expect("should only revoke a bid"); - it_should_allow_claim_on_or_after_claim_height(&rig).await.expect("should allow claim on or above height"); - it_should_allow_batch_transfers_refreshing_expire_height(&rig).await.expect("should allow batch transfers refresh expire height"); - it_should_allow_applying_script_in_batch(&rig).await.expect("should allow batch applying script"); - it_should_replace_mempool_bids(&rig).await.expect("should replace mempool bids"); - it_should_maintain_locktime_when_fee_bumping(&rig).await.expect("should maintain locktime"); - it_should_not_allow_register_or_transfer_to_same_space_multiple_times(&rig).await + it_should_open_a_space_for_auction(&rig) + .await + .expect("should open auction"); + it_should_allow_outbidding(&rig) + .await + .expect("should allow outbidding"); + it_should_insert_txout_for_bids(&rig) + .await + .expect("should insert txout"); + it_should_only_accept_forced_zero_value_bid_increments_and_revoke(&rig) + .await + .expect("should only revoke a bid"); + it_should_allow_claim_on_or_after_claim_height(&rig) + .await + .expect("should allow claim on or above height"); + it_should_allow_batch_transfers_refreshing_expire_height(&rig) + .await + .expect("should allow batch transfers refresh expire height"); + it_should_allow_applying_script_in_batch(&rig) + .await + .expect("should allow batch applying script"); + it_should_replace_mempool_bids(&rig) + .await + .expect("should replace mempool bids"); + it_should_maintain_locktime_when_fee_bumping(&rig) + .await + .expect("should maintain locktime"); + it_should_not_allow_register_or_transfer_to_same_space_multiple_times(&rig) + .await .expect("should not allow register/transfer multiple times"); it_can_batch_txs(&rig).await.expect("bump fee"); - it_can_use_reserved_op_codes(&rig).await.expect("should use reserved opcodes"); - it_should_allow_buy_sell(&rig).await.expect("should allow buy sell"); - it_should_allow_sign_verify_messages(&rig).await.expect("should sign verify"); + it_can_use_reserved_op_codes(&rig) + .await + .expect("should use reserved opcodes"); + it_should_allow_buy_sell(&rig) + .await + .expect("should allow buy sell"); + it_should_allow_sign_verify_messages(&rig) + .await + .expect("should sign verify"); // keep reorgs last as it can drop some txs from mempool and mess up wallet state - it_should_handle_reorgs(&rig).await.expect("should handle reorgs wallet"); + it_should_handle_reorgs(&rig) + .await + .expect("should handle reorgs wallet"); Ok(()) } diff --git a/node/tests/reorg_tests.rs b/client/tests/reorg_tests.rs similarity index 89% rename from node/tests/reorg_tests.rs rename to client/tests/reorg_tests.rs index 1b24f48..8c42cc9 100644 --- a/node/tests/reorg_tests.rs +++ b/client/tests/reorg_tests.rs @@ -1,7 +1,7 @@ use std::time::Duration; -use spaced::rpc::RpcClient; -use testutil::TestRig; +use spaces_client::rpc::RpcClient; +use spaces_testutil::TestRig; #[tokio::test] async fn it_should_resync_after_reorg_at_same_height() -> anyhow::Result<()> { diff --git a/node/src/lib.rs b/node/src/lib.rs deleted file mode 100644 index d528d5d..0000000 --- a/node/src/lib.rs +++ /dev/null @@ -1,33 +0,0 @@ -extern crate core; - -// needed for testutil -pub extern crate jsonrpsee; -pub extern crate log; - -use std::time::{Duration, Instant}; - -mod checker; -pub mod config; -pub mod node; -pub mod rpc; -pub mod source; -pub mod store; -pub mod sync; -pub mod wallets; -pub mod format; - -fn std_wait(mut predicate: F, wait: Duration) -where - F: FnMut() -> bool, -{ - let start = Instant::now(); - loop { - if predicate() { - break; - } - if start.elapsed() >= wait { - break; - } - std::thread::sleep(Duration::from_millis(10)); - } -} diff --git a/protocol/Cargo.toml b/protocol/Cargo.toml index 9b8dbf6..7fa402c 100644 --- a/protocol/Cargo.toml +++ b/protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "protocol" -version = "0.0.6" +name = "spaces_protocol" +version = "0.0.7" edition = "2021" [dependencies] diff --git a/protocol/src/constants.rs b/protocol/src/constants.rs index 4447dcb..9223dd6 100644 --- a/protocol/src/constants.rs +++ b/protocol/src/constants.rs @@ -16,6 +16,8 @@ pub struct ChainAnchor { pub height: u32, } +pub const SPACES_SIGNED_MSG_PREFIX: &[u8] = b"\x17Spaces Signed Message:\n"; + pub const RESERVED_SPACES: [&'static [u8]; 3] = [b"\x07example", b"\x04test", b"\x05local"]; /// The number of blocks between each rollout of new spaces for auction. @@ -111,7 +113,7 @@ pub mod bincode_impl { }; use bitcoin::{hashes::Hash, BlockHash}; - use crate::constants::ChainAnchor; + use crate::{alloc::borrow::ToOwned, constants::ChainAnchor}; impl Encode for ChainAnchor { fn encode(&self, encoder: &mut E) -> Result<(), EncodeError> { diff --git a/protocol/src/hasher.rs b/protocol/src/hasher.rs index fe86c37..faf81a9 100644 --- a/protocol/src/hasher.rs +++ b/protocol/src/hasher.rs @@ -57,10 +57,15 @@ impl From for SpaceKey { impl SpaceKey { #[inline(always)] pub fn from_raw(value: Hash) -> crate::errors::Result { - if (value[0] & 0b1000_0000) == 0 && (value[31] & 0b0000_0001) == 0 { + if Self::is_valid(&value) { return Ok(Self { 0: value }); } - return Err(crate::errors::Error::IO("bad space hash".to_string())); + Err(crate::errors::Error::IO("bad space hash".to_string())) + } + + #[inline(always)] + pub fn is_valid(value: &Hash) -> bool { + value[0] & 0b1000_0000 == 0 && value[31] & 0b0000_0001 == 0 } pub fn from_slice_unchecked(slice: &[u8]) -> Self { @@ -113,6 +118,11 @@ impl OutpointKey { let h = H::hash(&buffer); h.into() } + + #[inline(always)] + pub fn is_valid(value: &Hash) -> bool { + value[0] & 0b1000_0000 == 0 && value[31] & 0b0000_0001 == 0b0000_0001 + } } impl BidKey { diff --git a/protocol/src/lib.rs b/protocol/src/lib.rs index 49d4427..d5f1b4a 100644 --- a/protocol/src/lib.rs +++ b/protocol/src/lib.rs @@ -214,6 +214,8 @@ pub mod serde_bytes_impl { #[cfg(feature = "bincode")] pub mod bincode_bytes_impl { + use alloc::vec::Vec; + use bincode::{ de::Decoder, enc::Encoder, diff --git a/protocol/src/prepare.rs b/protocol/src/prepare.rs index d55e449..9054696 100644 --- a/protocol/src/prepare.rs +++ b/protocol/src/prepare.rs @@ -1,4 +1,4 @@ -use alloc::vec::Vec; +use alloc::{vec, vec::Vec}; use bitcoin::{ absolute::LockTime, diff --git a/testutil/Cargo.toml b/testutil/Cargo.toml index 31f914f..da6823b 100644 --- a/testutil/Cargo.toml +++ b/testutil/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "testutil" +name = "spaces_testutil" version = "0.0.1" edition = "2021" [dependencies] bitcoind = { version = "0.36.0", features = ["26_0"] } -spaced = { path = "../node" } +spaces_client = { path = "../client" } assert_cmd = "2.0.16" [build-dependencies] diff --git a/testutil/src/lib.rs b/testutil/src/lib.rs index ffb7a58..0d2188a 100644 --- a/testutil/src/lib.rs +++ b/testutil/src/lib.rs @@ -2,15 +2,15 @@ pub extern crate bitcoind; pub mod spaced; use std::{ + collections::HashMap, fs, io, path::{Path, PathBuf}, sync::Arc, time::Duration, }; -use std::collections::HashMap; -use ::spaced::{ - jsonrpsee::tokio, - node::protocol::{ + +use ::spaces_client::{ + client::spaces_protocol::{ bitcoin, bitcoin::{ absolute, address::NetworkChecked, block, block::Header, hashes::Hash, @@ -19,6 +19,7 @@ use ::spaced::{ Txid, }, }, + jsonrpsee::tokio, rpc::RpcClient, }; use anyhow::Result; @@ -27,12 +28,12 @@ use bitcoind::{ anyhow::{anyhow, Context}, bitcoincore_rpc::{ bitcoincore_rpc_json::{GetBlockTemplateModes, GetBlockTemplateRules}, - RpcApi, + json, RpcApi, }, tempfile::{tempdir, TempDir}, BitcoinD, }; -use bitcoind::bitcoincore_rpc::json; + use crate::spaced::SpaceD; // Path to the pre-created regtest testdata in build.rs diff --git a/testutil/src/spaced.rs b/testutil/src/spaced.rs index 35da50c..2057bbf 100644 --- a/testutil/src/spaced.rs +++ b/testutil/src/spaced.rs @@ -7,7 +7,7 @@ use std::{ use anyhow::Result; use assert_cmd::cargo::CommandCargoExt; use bitcoind::{anyhow, anyhow::anyhow, get_available_port, tempfile::tempdir}; -use spaced::{ +use spaces_client::{ jsonrpsee::{ http_client::{HttpClient, HttpClientBuilder}, tokio, diff --git a/veritas/.gitignore b/veritas/.gitignore new file mode 100644 index 0000000..0c6117d --- /dev/null +++ b/veritas/.gitignore @@ -0,0 +1 @@ +pkg \ No newline at end of file diff --git a/veritas/Cargo.toml b/veritas/Cargo.toml new file mode 100644 index 0000000..1ddd419 --- /dev/null +++ b/veritas/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "spaces_veritas" +version = "0.0.7" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +spaces_protocol = { path = "../protocol", default-features = false, features = ["std"]} +bincode = { version = "2.0.0-rc.3", default-features = false, features = ["alloc"]} +spacedb = { git = "https://github.com/spacesprotocol/spacedb", rev = "e07259ac6fc9cea1b06ee2cfdbb93d8c3039658a", default-features = false } + +# optional wasm feature +wasm-bindgen = { version ="0.2.100", optional = true } +js-sys = { version = "0.3.77", optional = true } + +# Compatibility to compile to WASM +getrandom = { version = "0.2.15", features = ["js"] } +ring = { version = "0.17.8", features = ["wasm32_unknown_unknown_js"] } +gloo-timers = { version = "0.3.0", features = ["futures"] } + +[dev-dependencies] +base64 = {version = "0.22.1", features = ["alloc"], default-features = false} +hex = "0.4.3" +log = "0.4.22" +env_logger = "0.11.6" + +[features] +std = [] +wasm = ["wasm-bindgen", "js-sys"] diff --git a/veritas/README.md b/veritas/README.md new file mode 100644 index 0000000..821ff0c --- /dev/null +++ b/veritas/README.md @@ -0,0 +1,107 @@ + +# 🔑 Veritas + +Veritas is a stateless way to verify Spaces on Bitcoin using a permissionless trust anchor (a 32-byte hash), without needing a full Bitcoin node on your phone! + + +## 🚀 What Can Your App Do? + +- **Scan or fetch a trust anchor** from a **trusted node** to sync instantly. +- **Use it to verify any `space -> pubkey` mapping** with a Merkle inclusion proof. +- **Messages & DNS packets are signed with the space's pubkey**—records can be resolved and updated off-chain without modifying the trust anchor itself. +- **Display the trust anchor for transparency**—users can compare it against public explorers or services they trust. + - Think of it like a [Signal safety number](https://support.signal.org/hc/en-us/articles/360007060632-What-is-a-safety-number-and-why-do-I-see-that-it-changed), except better! A **single** identifier for all contacts. +- **Re-scan or fetch** periodically to include recent changes, such as new space registrations or transfers. + +> **Note:** Trust anchors are **32-byte Merkle tree roots** that represent a **summary of the entire protocol state** at a specific Bitcoin block height. + +--- + +## Javascript Example + +### Verifying proofs + +```javascript +const veritas = new Veritas(); + +// Set up a trust anchor +const root = Buffer.from( + "a44ad8bca3184798d75f69b9c50bfbc67dd1bcf550a9ce3a943ff6501ab60693", + "hex" +); +veritas.addAnchor(root); + +const rawProof = Buffer.from( + "AQEAAouXDhe+rJKxqcvRzRIthc2QkNuPDt34M2NmW8nLoqk0AQACD5+x6CJLkmxgKPTyS0Nq9Ci03Lev9Fm20W+kyCzvewMBAAEAAvNWZU+az0t38K0pMm5Ny5fWGFZskajtKZ+On2Z4PkGqAQACXb+CBVIEjx7wDHZbG/FWKuczR8WgyHSelZBwXIzjflIBAAJo/bDo+osV3y5G7AGeMv6i/LMbCozs2tk3jUg0+0L8nwEAAQABAAEAAiMCqoVnipJoF4xoNhz7owXgN+ozXdgce3MZX/M7WCXOAG4seB00O3x87+y2CM1e1uZhmTkmmkyUwyjxv/IronYzADcBAQdtZW1wb29sAfzA3gEAAPuaAiJRIHj13+6+2Wc7tWB+ZswSvzvEKCzhUjuwUsQyFJX0f8SHAklClIvNFftzNbqMoAe7bdDpm4pnWyU6o+abgq+22xEgAiNAa7W4k9sjy7lYKzZtx1ag2VVcz+XzwDLPZU02XiIDAqh+BDASBJSQYgMZPd/BAgbND21I/8FFfcpHsJqqsb4lAnHXQmQvzKYfAhWXtBD687lb4qqZudMBPZY0UQsqNWBC", + "base64" +); + +// Verify a proof with veritas +const proof = veritas.verifyProof(rawProof); + +// Iterate over the proof entries +for (const {key, value: utxo} of proof.entries()) { + const space = utxo.getSpace(); + console.log('✅ Space: ', space.getName().toString()); + console.log('🔑 Public key: ', Buffer.from(utxo.getPublicKey()).toString('hex')); +} +``` + +### Verifying messages + +```javascript +const veritas = new Veritas(); + +// Set up a trust anchor +const anchor = Buffer.from( + "a44ad8bca3184798d75f69b9c50bfbc67dd1bcf550a9ce3a943ff6501ab60693", + "hex" +); +veritas.addAnchor(anchor); + +const rawProof = Buffer.from( + "AQEAAouXDhe+rJKxqcvRzRIthc2QkNuPDt34M2NmW8nLoqk0AQACD5+x6CJLkmxgKPTyS0Nq9Ci03Lev9Fm20W+kyCzvewMBAAEAAvNWZU+az0t38K0pMm5Ny5fWGFZskajtKZ+On2Z4PkGqAQACXb+CBVIEjx7wDHZbG/FWKuczR8WgyHSelZBwXIzjflIBAAJo/bDo+osV3y5G7AGeMv6i/LMbCozs2tk3jUg0+0L8nwEAAQABAAEAAiMCqoVnipJoF4xoNhz7owXgN+ozXdgce3MZX/M7WCXOAG4seB00O3x87+y2CM1e1uZhmTkmmkyUwyjxv/IronYzADcBAQdtZW1wb29sAfzA3gEAAPuaAiJRIHj13+6+2Wc7tWB+ZswSvzvEKCzhUjuwUsQyFJX0f8SHAklClIvNFftzNbqMoAe7bdDpm4pnWyU6o+abgq+22xEgAiNAa7W4k9sjy7lYKzZtx1ag2VVcz+XzwDLPZU02XiIDAqh+BDASBJSQYgMZPd/BAgbND21I/8FFfcpHsJqqsb4lAnHXQmQvzKYfAhWXtBD687lb4qqZudMBPZY0UQsqNWBC", + "base64" +); + +// Verify proof with veritas +const proof = veritas.verifyProof(rawProof); + +// Prepare space, message, and signature. +const space = new SLabel("@mempool"); +const msg = Buffer.from("hello world", "utf-8"); +const sig = Buffer.from( + "c13064e2bc671c5444f610110d7bf9cebe7a003cb09279fd0b04ab34415913c2cacd22e7795a49fe7310e3bcde4df58055d89ccc7cfbd002f612d2fe74271b4a", + "hex" +); + +// Find a UTXO associated with the space withtin the proof +// to verify a message signed with it +const utxo = proof.findSpace(space.computeHash()); +veritas.verifyMessage(utxo, msg, sig); + +console.log("✅ Verified message:", msg.toString("utf-8")); +console.log("- Signed by: ", space.toString()); +console.log("- Public Key:", Buffer.from(utxo.getPublicKey()).toString("hex")); +console.log("- Signature: ", sig.toString('hex')); +``` + + + +## Multiple anchors + +Since the Spaces client generates a new trust anchor every 6 hours to include recent updates, it’s recommended to retain older anchors to verify against older proofs while prioritizing the most recent one when available. Depending on your use case and the availability of older proofs, your app could need to re-scan/sync a new anchor once a day, week, month or even longer! + + +## Compiling from Source + +To compile from source, use `wasm-pack` you need to have clang installed. + +```bash +./wasm.sh +``` + + +## License + +Licensed under the MIT license. \ No newline at end of file diff --git a/veritas/example/index.js b/veritas/example/index.js new file mode 100644 index 0000000..b44f63d --- /dev/null +++ b/veritas/example/index.js @@ -0,0 +1,39 @@ +import {Veritas, SLabel} from "../pkg/spaces_veritas.js"; + +async function main() { + const veritas = new Veritas(); + + // Set up a trust anchor + const anchor = Buffer.from( + "a44ad8bca3184798d75f69b9c50bfbc67dd1bcf550a9ce3a943ff6501ab60693", + "hex" + ); + veritas.addAnchor(anchor); + + const rawProof = Buffer.from( + "AQEAAouXDhe+rJKxqcvRzRIthc2QkNuPDt34M2NmW8nLoqk0AQACD5+x6CJLkmxgKPTyS0Nq9Ci03Lev9Fm20W+kyCzvewMBAAEAAvNWZU+az0t38K0pMm5Ny5fWGFZskajtKZ+On2Z4PkGqAQACXb+CBVIEjx7wDHZbG/FWKuczR8WgyHSelZBwXIzjflIBAAJo/bDo+osV3y5G7AGeMv6i/LMbCozs2tk3jUg0+0L8nwEAAQABAAEAAiMCqoVnipJoF4xoNhz7owXgN+ozXdgce3MZX/M7WCXOAG4seB00O3x87+y2CM1e1uZhmTkmmkyUwyjxv/IronYzADcBAQdtZW1wb29sAfzA3gEAAPuaAiJRIHj13+6+2Wc7tWB+ZswSvzvEKCzhUjuwUsQyFJX0f8SHAklClIvNFftzNbqMoAe7bdDpm4pnWyU6o+abgq+22xEgAiNAa7W4k9sjy7lYKzZtx1ag2VVcz+XzwDLPZU02XiIDAqh+BDASBJSQYgMZPd/BAgbND21I/8FFfcpHsJqqsb4lAnHXQmQvzKYfAhWXtBD687lb4qqZudMBPZY0UQsqNWBC", + "base64" + ); + + // Verify a proof with veritas + const proof = veritas.verifyProof(rawProof); + + // Prepare space hash, message, and signature. + const space = new SLabel("@mempool"); + const message = Buffer.from("hello world", "utf-8"); + const signature = Buffer.from( + "c13064e2bc671c5444f610110d7bf9cebe7a003cb09279fd0b04ab34415913c2cacd22e7795a49fe7310e3bcde4df58055d89ccc7cfbd002f612d2fe74271b4a", + "hex" + ); + + // Find the corresponding UTXO withtin the proof to verify the message with its public key + const utxo = proof.findSpace(space.computeHash()); + veritas.verifyMessage(utxo, message, signature); + + console.log("✅ Verified message:", message.toString("utf-8")); + console.log("- Signed by: ", space.toString()); + console.log("- Public Key:", Buffer.from(utxo.getPublicKey()).toString("hex")); + console.log("- Signature: ", signature.toString('hex')); +} + +main(); diff --git a/veritas/example/package-lock.json b/veritas/example/package-lock.json new file mode 100644 index 0000000..169f330 --- /dev/null +++ b/veritas/example/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "example", + "version": "1.0.0", + "license": "ISC" + } + } +} diff --git a/veritas/example/package.json b/veritas/example/package.json new file mode 100644 index 0000000..13e0af2 --- /dev/null +++ b/veritas/example/package.json @@ -0,0 +1,12 @@ +{ + "name": "example", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC" +} diff --git a/veritas/src/lib.rs b/veritas/src/lib.rs new file mode 100644 index 0000000..221a6f3 --- /dev/null +++ b/veritas/src/lib.rs @@ -0,0 +1,198 @@ +#![cfg_attr(all(not(feature = "std"), not(test)), no_std)] + +mod wasm; + +extern crate alloc; + +use alloc::{collections::BTreeSet, vec::Vec}; + +use bincode::config; +use spacedb::{ + encode::SubTreeEncoder, + subtree::{SubTree, SubtreeIter}, + Hash, Sha256Hasher, VerifyError, +}; +use spaces_protocol::{ + bitcoin::{key::Secp256k1, secp256k1, secp256k1::VerifyOnly, OutPoint, XOnlyPublicKey}, + hasher, + hasher::{OutpointKey, SpaceKey}, + slabel::SLabel, + SpaceOut, +}; + +pub struct Veritas { + anchors: BTreeSet, + ctx: Secp256k1, +} + +pub struct Proof { + root: Hash, + inner: SubTree, +} + +pub struct ProofIter<'a> { + inner: SubtreeIter<'a>, +} + +pub enum Value { + Outpoint(OutPoint), + UTXO(SpaceOut), + Unknown(Vec), +} + +pub trait SpaceoutExt { + fn public_key(&self) -> Option; +} + +#[derive(Debug)] +pub enum Error { + MalformedSubtree, + MalformedValue, + KeyExists, + IncompleteProof, + KeyNotFound, + NoMatchingAnchor, + UnsupportedScriptPubKey, + InvalidSignature, + SignatureVerificationFailed, +} + +impl Veritas { + pub fn new() -> Self { + Self { + anchors: BTreeSet::new(), + ctx: Secp256k1::verification_only(), + } + } + + pub fn add_anchor(&mut self, anchor: hasher::Hash) { + self.anchors.insert(anchor); + } + + pub fn verify_proof(&self, proof: impl AsRef<[u8]>) -> Result { + let inner = SubTree::from_slice(proof.as_ref()).map_err(|_| Error::MalformedSubtree)?; + let root = inner.compute_root()?; + + if !self.anchors.contains(&root) { + return Err(Error::NoMatchingAnchor); + } + Ok(Proof { root, inner }) + } + + pub fn verify_schnorr(&self, pubkey: &[u8], digest: &[u8], sig: &[u8]) -> bool { + if digest.len() != 32 { + return false; + } + let sig = match secp256k1::schnorr::Signature::from_slice(sig) { + Err(_) => return false, + Ok(sig) => sig, + }; + let pubkey = match XOnlyPublicKey::from_slice(pubkey) { + Err(_) => return false, + Ok(pubkey) => pubkey, + }; + + let mut msg_digest = [0u8; 32]; + msg_digest.copy_from_slice(digest.as_ref()); + let msg_digest = secp256k1::Message::from_digest(msg_digest); + self.ctx + .verify_schnorr(&sig, &msg_digest, &pubkey) + .map(|_| true) + .unwrap_or(false) + } +} + +impl Proof { + pub fn iter(&self) -> ProofIter { + ProofIter { + inner: self.inner.iter(), + } + } + + pub fn root(&self) -> &Hash { + &self.root + } + + pub fn contains(&self, key: &Hash) -> Result { + self.inner.contains(key).map_err(|e| e.into()) + } + + /// Retrieves a UTXO leaf within the subtree specified the outpoint hash + pub fn get_utxo(&self, utxo_key: &Hash) -> Result, Error> { + let (_, value) = match self.inner.iter().find(|(k, _)| *k == utxo_key) { + None => return Ok(None), + Some(kv) => kv, + }; + let (utxo, _): (SpaceOut, _) = bincode::decode_from_slice(value, config::standard()) + .map_err(|_| Error::MalformedValue)?; + Ok(Some(utxo)) + } + + /// Retrieves a UTXO leaf containing the specified space + pub fn find_space(&self, space: &SLabel) -> Result, Error> { + for (_, v) in self.iter() { + match v { + Value::UTXO(utxo) => { + if utxo + .space + .as_ref() + .is_some_and(|s| s.name.as_ref() == space.as_ref()) + { + return Ok(Some(utxo)); + } + } + _ => continue, + } + } + Ok(None) + } +} + +impl From for Error { + fn from(e: spacedb::Error) -> Self { + match e { + spacedb::Error::Verify(e) => match e { + VerifyError::KeyExists => Error::KeyExists, + VerifyError::IncompleteProof => Error::IncompleteProof, + VerifyError::KeyNotFound => Error::KeyNotFound, + }, + _ => Error::MalformedSubtree, + } + } +} + +impl SpaceoutExt for SpaceOut { + fn public_key(&self) -> Option { + match self.script_pubkey.is_p2tr() { + true => XOnlyPublicKey::from_slice(&self.script_pubkey.as_bytes()[2..]).ok(), + false => None, + } + } +} + +impl Iterator for ProofIter<'_> { + type Item = (Hash, Value); + + fn next(&mut self) -> Option { + self.inner.next().map(|(k, v)| { + if OutpointKey::is_valid(k) { + let result = bincode::decode_from_slice(v.as_slice(), config::standard()) + .ok() + .map(|(raw, _)| Value::UTXO(raw)); + + return (*k, result.unwrap_or(Value::Unknown(v.clone()))); + } + if SpaceKey::is_valid(k) { + let result: Option = + bincode::serde::decode_from_slice(v.as_slice(), config::standard()) + .ok() + .map(|(raw, _)| raw); + return result + .map(|r| (*k, Value::Outpoint(r))) + .unwrap_or_else(|| (*k, Value::Unknown(v.clone()))); + } + + (*k, Value::Unknown(v.clone())) + }) + } +} diff --git a/veritas/src/wasm.rs b/veritas/src/wasm.rs new file mode 100644 index 0000000..2e9475c --- /dev/null +++ b/veritas/src/wasm.rs @@ -0,0 +1,329 @@ +#[cfg(feature = "wasm")] +mod wasm_api { + use alloc::{ + format, + string::{String, ToString}, + vec::Vec, + }; + use core::str::FromStr; + + use spaces_protocol::{ + bitcoin::hashes::{sha256, Hash, HashEngine}, + slabel::SLabel as NativeSLabel, + Covenant as NativeCovenant, Space as NativeSpace, SpaceOut as NativeSpaceOut, + }; + use wasm_bindgen::prelude::*; + + use crate::{Error, Proof as ProofNative, Value as ValueNative, Veritas as VeritasNative}; + + #[wasm_bindgen] + pub struct Veritas { + inner: VeritasNative, + } + + #[wasm_bindgen] + pub struct Proof { + inner: ProofNative, + } + + #[wasm_bindgen] + pub struct SpaceOut { + inner: NativeSpaceOut, + } + + #[wasm_bindgen] + pub struct Space { + inner: NativeSpace, + } + + #[wasm_bindgen] + pub struct SLabel { + inner: NativeSLabel, + } + + #[wasm_bindgen] + pub struct Covenant { + inner: NativeCovenant, + } + + #[wasm_bindgen] + pub struct TransferCovenant { + expire_height: u32, + data: Option>, + } + + #[wasm_bindgen] + pub struct BidCovenant { + burn_increment: u64, + signature: Vec, + total_burned: u64, + claim_height: Option, + } + + #[wasm_bindgen] + impl SLabel { + #[wasm_bindgen(constructor)] + pub fn new(space: &str) -> Result { + Ok(Self { + inner: NativeSLabel::from_str(space) + .map_err(|err| JsValue::from_str(&format!("{:?}", err)))?, + }) + } + + #[wasm_bindgen(js_name = "toString")] + pub fn to_string(&self) -> String { + self.inner.to_string() + } + + #[wasm_bindgen(js_name = "toBytes")] + pub fn to_bytes(&self) -> Vec { + self.inner.as_ref().to_vec() + } + } + + #[wasm_bindgen] + impl SpaceOut { + /// Constructs a SpaceOut from raw bytes. + #[wasm_bindgen(js_name = "fromBytes")] + pub fn from_bytes(data: &[u8]) -> Result { + let (native, _): (NativeSpaceOut, _) = + bincode::decode_from_slice(data, bincode::config::standard()) + .map_err(|e| JsValue::from_str(&format!("Deserialization error: {:?}", e)))?; + Ok(SpaceOut { inner: native }) + } + + #[wasm_bindgen(js_name = "getScriptPubkey")] + pub fn get_script_pubkey(&self) -> Vec { + self.inner.script_pubkey.to_bytes() + } + + #[wasm_bindgen(js_name = "getPublicKey")] + pub fn get_public_key(&self) -> Option> { + match self.inner.script_pubkey.is_p2tr() { + true => Some(self.inner.script_pubkey.as_bytes()[2..].to_vec()), + false => None, + } + } + + #[wasm_bindgen(js_name = "getValue")] + pub fn get_value(&self) -> u64 { + self.inner.value.to_sat() + } + + #[wasm_bindgen(js_name = "getSpace")] + pub fn get_space(&self) -> Option { + self.inner.space.clone().map(|s| Space { inner: s }) + } + } + + #[wasm_bindgen] + impl Space { + #[wasm_bindgen(js_name = "getName")] + pub fn get_name(&self) -> SLabel { + SLabel { + inner: self.inner.name.clone(), + } + } + + #[wasm_bindgen(js_name = "getCovenant")] + pub fn get_covenant(&self) -> Covenant { + Covenant { + inner: self.inner.covenant.clone(), + } + } + } + + #[wasm_bindgen] + impl Covenant { + /// Returns "bid", "transfer", or "reserved" to indicate the variant. + #[wasm_bindgen(js_name = "getKind")] + pub fn get_kind(&self) -> String { + match self.inner { + NativeCovenant::Bid { .. } => "bid".into(), + NativeCovenant::Transfer { .. } => "transfer".into(), + NativeCovenant::Reserved => "reserved".into(), + } + } + + /// If this covenant is a Bid, returns the bid details. + #[wasm_bindgen(js_name = "asBid")] + pub fn as_bid(&self) -> Option { + if let NativeCovenant::Bid { + ref burn_increment, + ref signature, + ref total_burned, + claim_height, + } = self.inner + { + Some(BidCovenant { + burn_increment: burn_increment.to_sat(), + signature: signature.as_ref().to_vec(), + total_burned: total_burned.to_sat(), + claim_height, + }) + } else { + None + } + } + + /// If this covenant is a Transfer, returns the transfer details. + #[wasm_bindgen(js_name = "asTransfer")] + pub fn as_transfer(&self) -> Option { + if let NativeCovenant::Transfer { + expire_height, + ref data, + } = self.inner + { + Some(TransferCovenant { + expire_height, + data: data.clone().map(|d| d.to_vec()), + }) + } else { + None + } + } + } + + #[wasm_bindgen] + impl BidCovenant { + #[wasm_bindgen(js_name = "getBurnIncrement")] + pub fn get_burn_increment(&self) -> u64 { + self.burn_increment + } + + #[wasm_bindgen(js_name = "getSignature")] + pub fn get_signature(&self) -> Vec { + self.signature.clone() + } + + #[wasm_bindgen(js_name = "getTotalBurned")] + pub fn total_burned(&self) -> u64 { + self.total_burned + } + + #[wasm_bindgen(js_name = "getClaimHeight")] + pub fn claim_height(&self) -> Option { + self.claim_height + } + } + + #[wasm_bindgen] + impl TransferCovenant { + #[wasm_bindgen(js_name = "getExpireHeight")] + pub fn get_expire_height(&self) -> u32 { + self.expire_height + } + + #[wasm_bindgen(js_name = "getData")] + pub fn get_data(&self) -> Option> { + self.data.clone() + } + } + + #[wasm_bindgen] + impl Veritas { + /// Creates a new Veritas instance. + #[wasm_bindgen(constructor)] + pub fn new() -> Veritas { + Veritas { + inner: VeritasNative::new(), + } + } + + /// Adds an anchor. + /// + /// The provided `anchor` must be a 32‑byte array (passed as a Uint8Array). + #[wasm_bindgen(js_name = "addAnchor")] + pub fn add_anchor(&mut self, anchor: &[u8]) -> Result<(), JsValue> { + let hash = read_hash(anchor)?; + self.inner.add_anchor(hash); + Ok(()) + } + + /// Verifies a proof. + #[wasm_bindgen(js_name = "verifyProof")] + pub fn verify_proof(&self, proof: &[u8]) -> Result { + self.inner + .verify_proof(proof) + .map(|p| Proof { inner: p }) + .map_err(|e| error_to_jsvalue(e)) + } + + #[wasm_bindgen(js_name = "verifySchnorr")] + pub fn verify_schnorr(&self, pubkey: &[u8], digest: &[u8], signature: &[u8]) -> bool { + self.inner.verify_schnorr(pubkey, digest, signature) + } + + #[wasm_bindgen(js_name = "sha256")] + pub fn sha256(data: &[u8]) -> Result, JsValue> { + let mut engine = sha256::Hash::engine(); + engine.input(data); + let h = sha256::Hash::from_engine(engine); + Ok(h.to_byte_array().to_vec()) + } + } + + #[wasm_bindgen] + impl Proof { + /// Returns the proof’s root hash. + #[wasm_bindgen(js_name = "getRoot")] + pub fn get_root(&self) -> Vec { + self.inner.root.to_vec() + } + + /// Checks whether a given key (a 32‑byte array) provably exists or not exists + #[wasm_bindgen] + pub fn contains(&self, key: &[u8]) -> Result { + let hash = read_hash(key)?; + self.inner.contains(&hash).map_err(|e| error_to_jsvalue(e)) + } + + #[wasm_bindgen(js_name = "findSpace")] + pub fn find_space(&self, space: &SLabel) -> Result, JsValue> { + Ok(self + .inner + .find_space(&space.inner) + .map_err(|e| error_to_jsvalue(e))? + .map(|out| SpaceOut { inner: out })) + } + + /// Returns all proof entries as an array of objects. + #[wasm_bindgen] + pub fn entries(&self) -> Result { + let entries = js_sys::Array::new(); + for (k, v) in self.inner.iter() { + let entry = js_sys::Object::new(); + + let key_array = js_sys::Uint8Array::from(k.as_ref()); + js_sys::Reflect::set(&entry, &JsValue::from_str("key"), &key_array.into())?; + + // Convert the value. + let value_js = match v { + ValueNative::Outpoint(ref op) => JsValue::from_str(&op.to_string()), + ValueNative::UTXO(ref utxo) => JsValue::from(SpaceOut { + inner: utxo.clone(), + }), + ValueNative::Unknown(ref bytes) => { + JsValue::from(js_sys::Uint8Array::from(&bytes[..])) + } + }; + js_sys::Reflect::set(&entry, &JsValue::from_str("value"), &value_js)?; + entries.push(&entry); + } + Ok(entries.into()) + } + } + + fn read_hash(hash: &[u8]) -> Result<[u8; 32], JsValue> { + if hash.len() != 32 { + return Err(JsValue::from_str("hash must be 32 bytes")); + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&hash); + Ok(arr) + } + + fn error_to_jsvalue(e: Error) -> JsValue { + JsValue::from_str(&format!("{:?}", e)) + } +} diff --git a/veritas/wasm.sh b/veritas/wasm.sh new file mode 100755 index 0000000..3abbae4 --- /dev/null +++ b/veritas/wasm.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -e + +if [ -z "$CC" ]; then + OS_TYPE=$(uname) + if [ "$OS_TYPE" = "Darwin" ]; then + if [ -x "/opt/homebrew/opt/llvm/bin/clang" ]; then + CC="/opt/homebrew/opt/llvm/bin/clang" + else + echo "Homebrew LLVM clang not found at /opt/homebrew/opt/llvm/bin/clang." + echo "Please specify the clang path using the CC environment variable." + exit 1 + fi + elif [ "$OS_TYPE" = "Linux" ]; then + if command -v clang >/dev/null 2>&1; then + CC=$(command -v clang) + else + echo "clang not found in your PATH." + echo "Please specify the clang path using the CC environment variable." + exit 1 + fi + else + echo "Unsupported OS: $OS_TYPE. Please specify the clang path using the CC environment variable." + exit 1 + fi +fi + +echo "Using CC: $CC" + +CC="$CC" wasm-pack build --target nodejs --features wasm --no-default-features + +NEW_NAME="@spacesprotocol/veritas" +PACKAGE_JSON="./pkg/package.json" + +# Update the "name" and "license" fields in package.json using jq +if [ -f "$PACKAGE_JSON" ]; then + jq --arg newName "$NEW_NAME" --arg license "Apache-2.0" '.name = $newName | .license = $license' "$PACKAGE_JSON" > "${PACKAGE_JSON}.tmp" && \ + mv "${PACKAGE_JSON}.tmp" "$PACKAGE_JSON" +else + echo "Error: $PACKAGE_JSON not found." + exit 1 +fi diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 61bec4e..9390d72 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -1,9 +1,10 @@ [package] -name = "wallet" -version = "0.0.6" +name = "spaces_wallet" +version = "0.0.7" edition = "2021" [dependencies] +spaces_protocol = { path = "../protocol", features = ["std"], version = "*" } 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"] } @@ -14,7 +15,6 @@ serde = { version = "^1.0", features = ["derive"] } serde_json = "1.0" bincode = { version = "2.0.0-rc.3", features = ["serde"] } jsonrpc = "0.18.0" -protocol = { path = "../protocol", features = ["std"], version = "*" } ctrlc = "3.4.4" hex = "0.4.3" log = "0.4.21" diff --git a/wallet/src/address.rs b/wallet/src/address.rs index f542c65..57b4c81 100644 --- a/wallet/src/address.rs +++ b/wallet/src/address.rs @@ -2,7 +2,7 @@ use core::{fmt, str::FromStr}; use bech32::{primitives::decode::SegwitHrpstringError, Hrp}; use bitcoin::blockdata::script::witness_version::WitnessVersion; -use protocol::{ +use spaces_protocol::{ bitcoin, bitcoin::{ address::{Address, ParseError}, diff --git a/wallet/src/builder.rs b/wallet/src/builder.rs index bd862b9..5f94516 100644 --- a/wallet/src/builder.rs +++ b/wallet/src/builder.rs @@ -21,17 +21,17 @@ use bitcoin::{ Amount, FeeRate, Network, OutPoint, Psbt, Script, ScriptBuf, Sequence, Transaction, TxOut, Txid, Weight, Witness, }; -use protocol::{ +use spaces_protocol::{ bitcoin::absolute::Height, constants::{BID_PSBT_INPUT_SEQUENCE, BID_PSBT_TX_VERSION}, script::SpaceScript, Covenant, FullSpaceOut, Space, }; + use crate::{ - address::SpaceAddress, - DoubleUtxo, FullTxOut, SpaceScriptSigningInfo, SpacesWallet, + address::SpaceAddress, tx_event::TxRecord, DoubleUtxo, FullTxOut, SpaceScriptSigningInfo, + SpacesWallet, }; -use crate::tx_event::TxRecord; #[derive(Debug, Clone)] pub struct Builder { @@ -60,7 +60,7 @@ pub struct BuilderIterator<'a> { force: bool, median_time: u64, confirmed_only: bool, - unspendables: Vec + unspendables: Vec, } pub enum BuilderStack { @@ -241,7 +241,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); @@ -409,8 +409,6 @@ impl Builder { } } - - builder.fee_rate(fee_rate); let r = builder.finish().map_err(|e| match e { CreateTxError::CoinSelection(e) if confirmed_only => { @@ -484,7 +482,6 @@ impl Iterator for BuilderIterator<'_> { self.fee_rate, self.dust, self.confirmed_only, - ) { Ok(prep) => prep, Err(err) => return Some(Err(err)), @@ -527,7 +524,9 @@ impl Iterator for BuilderIterator<'_> { })); } - for ((signing, commitment), context) in reveals_iter.zip(commitments_iter).zip(contexts) { + for ((signing, commitment), context) in + reveals_iter.zip(commitments_iter).zip(contexts) + { // script applies to every space in context for transfer in context.iter() { detailed_tx.add_commitment( @@ -585,11 +584,7 @@ impl Iterator for BuilderIterator<'_> { if !params.sends.is_empty() { // TODO: resolved address recipient for send in ¶ms.sends { - detailed_tx.add_send( - send.amount, - None, - send.recipient.script_pubkey(), - ); + detailed_tx.add_send(send.amount, None, send.recipient.script_pubkey()); } } Some(Ok(detailed_tx)) @@ -755,7 +750,7 @@ impl Builder { }; // Always create a few more bidouts for future transactions - const EXTRA_BIDOUTS : u8 = 2; + const EXTRA_BIDOUTS: u8 = 2; // check how many bid outputs we need to create let auction_outputs = match self.bidouts { None => { @@ -950,7 +945,7 @@ impl Builder { network: Network, name: &str, ) -> anyhow::Result { - let sname = protocol::slabel::SLabel::from_str(name).expect("valid space name"); + let sname = spaces_protocol::slabel::SLabel::from_str(name).expect("valid space name"); let nop = SpaceScript::nop_script(SpaceScript::create_open(sname)); SpaceScriptSigningInfo::new(network, nop) } @@ -1021,9 +1016,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( diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 2eb3e1f..548d0fb 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -1,45 +1,61 @@ -use std::{ - collections::{BTreeMap}, - fmt::Debug, - fs, - path::PathBuf, -}; -use std::ops::Mul; -use std::str::FromStr; +use std::{collections::BTreeMap, fmt::Debug, fs, ops::Mul, path::PathBuf, str::FromStr}; + use anyhow::{anyhow, Context}; -use bdk_wallet::{chain, chain::BlockId, coin_selection::{CoinSelectionAlgorithm, CoinSelectionResult, Excess, InsufficientFunds}, rusqlite::Connection, tx_builder::TxOrdering, AddressInfo, KeychainKind, LocalOutput, PersistedWallet, SignOptions, TxBuilder, Update, Wallet, WalletTx, WeightedUtxo}; -use bdk_wallet::chain::{ChainPosition, Indexer}; -use bdk_wallet::chain::local_chain::{CannotConnectError, LocalChain}; -use bdk_wallet::chain::tx_graph::CalculateFeeError; -use bdk_wallet::keys::DescriptorSecretKey; +use bdk_wallet::{ + chain, + chain::{ + local_chain::{CannotConnectError, LocalChain}, + tx_graph::CalculateFeeError, + BlockId, ChainPosition, Indexer, + }, + coin_selection::{CoinSelectionAlgorithm, CoinSelectionResult, Excess, InsufficientFunds}, + keys::DescriptorSecretKey, + rusqlite::Connection, + tx_builder::TxOrdering, + AddressInfo, KeychainKind, LocalOutput, PersistedWallet, SignOptions, TxBuilder, Update, + Wallet, WalletTx, WeightedUtxo, +}; use bincode::config; -use bitcoin::{absolute::{Height, LockTime}, key::rand::RngCore, psbt, psbt::raw::ProprietaryKey, script, sighash::{Prevouts, SighashCache}, taproot, taproot::LeafVersion, Amount, Block, BlockHash, FeeRate, Network, OutPoint, Psbt, Sequence, TapLeafHash, TapSighashType, Transaction, TxIn, TxOut, Txid, VarInt, Weight, Witness}; -use bitcoin::bip32::{ChildNumber}; -use bitcoin::consensus::Encodable; -use bitcoin::hashes::{sha256d, Hash, HashEngine}; -use bitcoin::key::{TapTweak, TweakedKeypair}; -use bitcoin::transaction::Version; -use secp256k1::{schnorr, Message}; -use secp256k1::schnorr::Signature; -use protocol::{bitcoin::{ - constants::genesis_block, - key::{rand, UntweakedKeypair}, - opcodes, - taproot::{ControlBlock, TaprootBuilder}, - Address, ScriptBuf, XOnlyPublicKey, -}, prepare::{is_magic_lock_time, TrackableOutput}, Covenant, FullSpaceOut, Space}; +use bitcoin::{ + absolute::{Height, LockTime}, + bip32::ChildNumber, + key::{rand::RngCore, TapTweak, TweakedKeypair}, + psbt, + psbt::raw::ProprietaryKey, + script, + sighash::{Prevouts, SighashCache}, + taproot, + taproot::LeafVersion, + transaction::Version, + Amount, Block, BlockHash, FeeRate, Network, OutPoint, Psbt, Sequence, TapLeafHash, + TapSighashType, Transaction, TxIn, TxOut, Txid, Weight, Witness, +}; +use secp256k1::{schnorr, schnorr::Signature, Message}; use serde::{ser::SerializeSeq, Deserialize, Deserializer, Serialize, Serializer}; -use protocol::constants::{BID_PSBT_INPUT_SEQUENCE, BID_PSBT_TX_LOCK_TIME}; -use protocol::hasher::{KeyHasher, SpaceKey}; -use protocol::prepare::DataSource; -use protocol::slabel::SLabel; +use spaces_protocol::{ + bitcoin::{ + constants::genesis_block, + key::{rand, UntweakedKeypair}, + opcodes, + taproot::{ControlBlock, TaprootBuilder}, + Address, ScriptBuf, XOnlyPublicKey, + }, + constants::{BID_PSBT_INPUT_SEQUENCE, BID_PSBT_TX_LOCK_TIME}, + hasher::{KeyHasher, SpaceKey}, + prepare::{is_magic_lock_time, DataSource, TrackableOutput}, + slabel::SLabel, + Covenant, FullSpaceOut, Space, +}; + use crate::{ address::SpaceAddress, - builder::{is_connector_dust, is_space_dust, SpacesAwareCoinSelection}, - tx_event::TxEvent, + builder::{ + is_connector_dust, is_space_dust, space_dust, tap_key_spend_weight, + SpacesAwareCoinSelection, + }, + nostr::NostrEvent, + tx_event::{TxEvent, TxEventKind, TxRecord}, }; -use crate::builder::{space_dust, tap_key_spend_weight}; -use crate::tx_event::{TxEventKind, TxRecord}; pub extern crate bdk_wallet; pub extern crate bitcoin; @@ -48,6 +64,7 @@ extern crate core; pub mod address; pub mod builder; pub mod export; +pub mod nostr; mod rusqlite_impl; pub mod tx_event; @@ -171,30 +188,40 @@ impl SpacesWallet { Some(hash) => hash, }; - let spaces_wallet = if let Some(wallet) = - Wallet::load() - .check_network(config.network) - .descriptor(KeychainKind::External, Some(config.space_descriptors.external.clone())) - .descriptor(KeychainKind::Internal, Some(config.space_descriptors.internal.clone())) - .lookahead(50) - .extract_keys() - .load_wallet(&mut conn).context("could not load wallet")? { + let spaces_wallet = if let Some(wallet) = Wallet::load() + .check_network(config.network) + .descriptor( + KeychainKind::External, + Some(config.space_descriptors.external.clone()), + ) + .descriptor( + KeychainKind::Internal, + Some(config.space_descriptors.internal.clone()), + ) + .lookahead(50) + .extract_keys() + .load_wallet(&mut conn) + .context("could not load wallet")? + { wallet } else { Wallet::create( config.space_descriptors.external.clone(), config.space_descriptors.internal.clone(), ) - .lookahead(50) - .network(config.network) - .genesis_hash(genesis_hash) - .create_wallet(&mut conn).context("could not create wallet")? + .lookahead(50) + .network(config.network) + .genesis_hash(genesis_hash) + .create_wallet(&mut conn) + .context("could not create wallet")? }; - - let tx = conn.transaction().context("could not create wallet db transaction")?; + let tx = conn + .transaction() + .context("could not create wallet db transaction")?; Self::init_sqlite_tables(&tx).context("could not initialize wallet db tables")?; - tx.commit().context("could not commit wallet db transaction")?; + tx.commit() + .context("could not commit wallet db transaction")?; let wallet = Self { config, @@ -243,16 +270,16 @@ impl SpacesWallet { pub fn insert_checkpoint(&mut self, checkpoint: BlockId) -> Result<(), CannotConnectError> { let mut cp = self.internal.latest_checkpoint(); cp = cp.insert(checkpoint); - self.internal - .apply_update(Update { - chain: Some(cp), - ..Default::default() - }) + self.internal.apply_update(Update { + chain: Some(cp), + ..Default::default() + }) } - pub fn transactions(&self) -> impl Iterator + '_ { - self.internal.transactions().filter(|tx| !is_revert_tx(tx) && - self.internal.spk_index().is_tx_relevant(&tx.tx_node)) + pub fn transactions(&self) -> impl Iterator + '_ { + self.internal + .transactions() + .filter(|tx| !is_revert_tx(tx) && self.internal.spk_index().is_tx_relevant(&tx.tx_node)) } pub fn sent_and_received(&self, tx: &Transaction) -> (Amount, Amount) { @@ -263,33 +290,51 @@ impl SpacesWallet { self.internal.calculate_fee(tx) } - pub fn build_tx(&mut self, unspendables: Vec, confirmed_only: bool) - -> anyhow::Result> { + pub fn build_tx( + &mut self, + unspendables: Vec, + confirmed_only: bool, + ) -> anyhow::Result> { self.create_builder(unspendables, None, confirmed_only) } - pub fn list_spaces_outpoints(&self, src: &mut impl DataSource) -> anyhow::Result> { + pub fn list_spaces_outpoints( + &self, + src: &mut impl DataSource, + ) -> anyhow::Result> { let mut outs = Vec::new(); for unspent in self.list_unspent() { - if src.get_spaceout(&unspent.outpoint)?.and_then(|out| out.space).is_some() { + if src + .get_spaceout(&unspent.outpoint)? + .and_then(|out| out.space) + .is_some() + { outs.push(unspent.outpoint); } } Ok(outs) } - pub fn build_fee_bump(&mut self, unspendables: Vec, txid: Txid, fee_rate: FeeRate) -> anyhow::Result> { + pub fn build_fee_bump( + &mut self, + unspendables: Vec, + txid: Txid, + fee_rate: FeeRate, + ) -> anyhow::Result> { let events = self.get_tx_events(txid)?; for event in events { match event.kind { - TxEventKind::Bid => { - match self.get_tx(txid) { - Some(tx) => if !tx.chain_position.is_confirmed() { - return Err(anyhow!("Bid with a higher fee on `{}` to replace this tx", event.space.expect("space"))) + TxEventKind::Bid => match self.get_tx(txid) { + Some(tx) => { + if !tx.chain_position.is_confirmed() { + return Err(anyhow!( + "Bid with a higher fee on `{}` to replace this tx", + event.space.expect("space") + )); } - _ => continue, } - } + _ => continue, + }, _ => {} } } @@ -303,24 +348,20 @@ impl SpacesWallet { replace: Option<(Txid, FeeRate)>, confirmed_only: bool, ) -> anyhow::Result> { - let selection = SpacesAwareCoinSelection::new( - unspendables, confirmed_only, - ); + let selection = SpacesAwareCoinSelection::new(unspendables, confirmed_only); let mut builder = match replace { - None => { - self.internal.build_tx().coin_selection(selection) - } + None => self.internal.build_tx().coin_selection(selection), Some((txid, fee_rate)) => { let previous_tx_lock_time = match self.get_tx(txid) { None => return Err(anyhow::anyhow!("No wallet tx {} found", txid)), Some(tx) => tx.tx_node.lock_time, }; - let mut builder = self.internal.build_fee_bump(txid)? + let mut builder = self + .internal + .build_fee_bump(txid)? .coin_selection(selection); - builder - .nlocktime(previous_tx_lock_time) - .fee_rate(fee_rate); + builder.nlocktime(previous_tx_lock_time).fee_rate(fee_rate); builder } }; @@ -333,11 +374,11 @@ impl SpacesWallet { self.internal.is_mine(script) } - pub fn list_unspent(&self) -> impl Iterator + '_ { + pub fn list_unspent(&self) -> impl Iterator + '_ { self.internal.list_unspent() } - pub fn list_output(&self) -> impl Iterator + '_ { + pub fn list_output(&self) -> impl Iterator + '_ { self.internal.list_output() } @@ -346,7 +387,16 @@ impl SpacesWallet { TxEvent::get_latest_events(&db_tx).context("could not read latest events") } - pub fn sign_message(&mut self, src: &mut impl DataSource, space: &str, msg: impl AsRef<[u8]>) -> anyhow::Result { + pub fn sign_event( + &mut self, + src: &mut impl DataSource, + space: &str, + mut event: NostrEvent, + ) -> anyhow::Result { + if event.space().is_some_and(|s| s != space) { + return Err(anyhow::anyhow!("Space tag does not match specified space")); + } + let label = SLabel::from_str(space)?; let space_key = SpaceKey::from(H::hash(label.as_ref())); let outpoint = match src.get_space_outpoint(&space_key)? { @@ -358,22 +408,24 @@ impl SpacesWallet { Some(utxo) => utxo, }; - let keypair = self.get_taproot_keypair(utxo.keychain, utxo.derivation_index) + let keypair = self + .get_taproot_keypair(utxo.keychain, utxo.derivation_index) .context("Could not derive taproot keypair to sign message")?; - let msg_hash = signed_msg_hash(msg); - let msg_to_sign = secp256k1::Message::from_digest(msg_hash.to_byte_array()); - let ctx = secp256k1::Secp256k1::new(); - Ok(ctx.sign_schnorr(&msg_to_sign, &keypair.to_inner())) + event.sign(secp256k1::Secp256k1::new(), &keypair.to_inner())?; + Ok(event) } - pub fn verify_message( + pub fn verify_event( src: &mut impl DataSource, space: &str, - msg: impl AsRef<[u8]>, - signature: &Signature - ) -> anyhow::Result<()> { - let label = SLabel::from_str(space)?; + mut event: NostrEvent, + ) -> anyhow::Result { + if event.space().is_some_and(|s| s != space) { + return Err(anyhow::anyhow!("Space tag does not match specified space")); + } + + let label = SLabel::from_str(&space)?; let space_key = SpaceKey::from(H::hash(label.as_ref())); let outpoint = match src.get_space_outpoint(&space_key)? { None => return Err(anyhow::anyhow!("Space not found")), @@ -384,24 +436,35 @@ impl SpacesWallet { Some(spaceout) => spaceout, }; if !spaceout.script_pubkey.is_witness_program() { - return Err(anyhow::anyhow!("Cannot verify non-taproot spaces")) + return Err(anyhow::anyhow!("Cannot verify non-taproot spaces")); } let script_bytes = spaceout.script_pubkey.as_bytes(); if script_bytes.len() != secp256k1::constants::SCHNORR_PUBLIC_KEY_SIZE + 2 { return Err(anyhow::anyhow!("Expected a schnorr public key")); } - let pubkey = XOnlyPublicKey::from_slice(&script_bytes[2..])?; - let ctx = secp256k1::Secp256k1::new(); - let msg_hash = signed_msg_hash(msg); - let msg_to_sign = Message::from_digest(msg_hash.to_byte_array()); - ctx.verify_schnorr(signature, &msg_to_sign, &pubkey)?; - Ok(()) + match event.pubkey { + None => { + event.pubkey = Some(pubkey); + } + Some(actual) => { + if actual != pubkey { + return Err(anyhow::anyhow!("Event pubkey doesn't match space pubkey")); + } + } + } + + let ctx = secp256k1::Secp256k1::new(); + event.verify(ctx); + Ok(event) } - pub fn list_unspent_with_details(&mut self, store: &mut impl DataSource) -> anyhow::Result> { + pub fn list_unspent_with_details( + &mut self, + store: &mut impl DataSource, + ) -> anyhow::Result> { let mut wallet_outputs = Vec::new(); for output in self.internal.list_unspent() { let mut details = WalletOutput { @@ -422,7 +485,12 @@ impl SpacesWallet { /// Checks the mempool for dropped bid transactions and reverts them in the wallet’s Tx graph, /// reclaiming any "stuck" funds. This is necessary because continuously scanning the entire /// mainnet mempool would be resource-intensive to fetch from Bitcoin Core RPC. - pub fn update_unconfirmed_bids(&mut self, mem: impl Mempool, height: u32, data_source: &mut impl DataSource) -> anyhow::Result> { + pub fn update_unconfirmed_bids( + &mut self, + mem: impl Mempool, + height: u32, + data_source: &mut impl DataSource, + ) -> anyhow::Result> { let unconfirmed_bids = self.unconfirmed_bids()?; let mut revert_txs = Vec::new(); for (bid, outpoint) in unconfirmed_bids { @@ -431,7 +499,11 @@ impl SpacesWallet { continue; } // bid dropped from mempool perhaps it was confirmed spending outpoint? - if data_source.get_spaceout(&outpoint).context("could not fetch spaceout from db")?.is_none() { + if data_source + .get_spaceout(&outpoint) + .context("could not fetch spaceout from db")? + .is_none() + { continue; } if let Some((revert, seen)) = revert_unconfirmed_bid_tx(&bid, outpoint) { @@ -454,29 +526,35 @@ impl SpacesWallet { /// to check if they have been replaced. pub fn unconfirmed_bids(&mut self) -> anyhow::Result> { let txids: Vec<_> = { - let unconfirmed: Vec<_> = self.transactions() - .filter(|x| !x.chain_position.is_confirmed()).collect(); + let unconfirmed: Vec<_> = self + .transactions() + .filter(|x| !x.chain_position.is_confirmed()) + .collect(); unconfirmed.iter().map(|x| x.tx_node.txid).collect() }; let bid_txids = { let db_tx = self.connection.transaction()?; TxEvent::filter_bids(&db_tx, txids)? }; - let bid_txs: Vec<_> = self.transactions() + let bid_txs: Vec<_> = self + .transactions() .filter(|tx| !tx.chain_position.is_confirmed()) .filter_map(|tx| { - bid_txids.iter().find(|(bid_txid, _)| *bid_txid == tx.tx_node.txid).map(|(_, bid_outpoint)| { - (tx, *bid_outpoint) - }) - }).collect(); + bid_txids + .iter() + .find(|(bid_txid, _)| *bid_txid == tx.tx_node.txid) + .map(|(_, bid_outpoint)| (tx, *bid_outpoint)) + }) + .collect(); Ok(bid_txs) } pub fn get_tx_events(&mut self, txid: Txid) -> anyhow::Result> { - let db_tx = self.connection.transaction() + let db_tx = self + .connection + .transaction() .context("could not get wallet db transaction")?; - let result = TxEvent::all(&db_tx, txid) - .context("could not get wallet db tx events")?; + let result = TxEvent::all(&db_tx, txid).context("could not get wallet db tx events")?; Ok(result) } @@ -534,7 +612,11 @@ impl SpacesWallet { self.internal.apply_unconfirmed_txs(vec![(tx, seen)]); } - pub fn apply_unconfirmed_tx_record(&mut self, tx_record: TxRecord, seen: u64) -> anyhow::Result<()> { + pub fn apply_unconfirmed_tx_record( + &mut self, + tx_record: TxRecord, + seen: u64, + ) -> anyhow::Result<()> { let txid = tx_record.tx.compute_txid(); self.apply_unconfirmed_tx(tx_record.tx, seen); @@ -543,7 +625,9 @@ impl SpacesWallet { self.internal.insert_txout(outpoint, txout); } - let db_tx = self.connection.transaction() + let db_tx = self + .connection + .transaction() .context("could not create wallet db transaction")?; for event in tx_record.events { TxEvent::insert( @@ -553,9 +637,12 @@ impl SpacesWallet { event.space, event.previous_spaceout, event.details, - ).context("could not insert tx event into wallet db")?; + ) + .context("could not insert tx event into wallet db")?; } - db_tx.commit().context("could not commit tx events to wallet db")?; + db_tx + .commit() + .context("could not commit tx events to wallet db")?; Ok(()) } @@ -565,10 +652,7 @@ impl SpacesWallet { } /// List outputs that can be safely auctioned off - pub fn list_bidouts( - &mut self, - confirmed_only: bool, - ) -> anyhow::Result> { + pub fn list_bidouts(&mut self, confirmed_only: bool) -> anyhow::Result> { let mut unspent: Vec = self.list_unspent().collect(); let mut not_auctioned = vec![]; @@ -642,7 +726,12 @@ impl SpacesWallet { Ok(not_auctioned) } - pub fn buy(&mut self, src: &mut impl DataSource, listing: &Listing, fee_rate: FeeRate) -> anyhow::Result { + pub fn buy( + &mut self, + src: &mut impl DataSource, + listing: &Listing, + fee_rate: FeeRate, + ) -> anyhow::Result { let (seller, spaceout) = Self::verify_listing::(src, &listing)?; let mut witness = Witness::new(); @@ -651,7 +740,7 @@ impl SpacesWallet { signature: listing.signature, sighash_type: TapSighashType::SinglePlusAnyoneCanPay, } - .to_vec(), + .to_vec(), ); let funded_psbt = { @@ -691,12 +780,20 @@ impl SpacesWallet { Ok(tx) } - pub fn verify_listing(src: &mut impl DataSource, listing: &Listing) -> anyhow::Result<(SpaceAddress, FullSpaceOut)> { + pub fn verify_listing( + src: &mut impl DataSource, + listing: &Listing, + ) -> anyhow::Result<(SpaceAddress, FullSpaceOut)> { let label = SLabel::from_str(&listing.space)?; let space_key = SpaceKey::from(H::hash(label.as_ref())); let outpoint = match src.get_space_outpoint(&space_key)? { - None => return Err(anyhow::anyhow!("Unknown space {} - no outpoint found", listing.space)), - Some(outpoint) => outpoint + None => { + return Err(anyhow::anyhow!( + "Unknown space {} - no outpoint found", + listing.space + )) + } + Some(outpoint) => outpoint, }; let spaceout = match src.get_spaceout(&outpoint)? { @@ -707,22 +804,36 @@ impl SpacesWallet { if spaceout.space.is_none() { return Err(anyhow!("No associated space")); } - if !matches!(spaceout.space.as_ref().unwrap().covenant, Covenant::Transfer { ..}) { + if !matches!( + spaceout.space.as_ref().unwrap().covenant, + Covenant::Transfer { .. } + ) { return Err(anyhow::anyhow!("Space not registered")); } - let recipient = Self::verify_listing_signature(&listing, outpoint, TxOut { - value: spaceout.value, - script_pubkey: spaceout.script_pubkey.clone(), - })?; + let recipient = Self::verify_listing_signature( + &listing, + outpoint, + TxOut { + value: spaceout.value, + script_pubkey: spaceout.script_pubkey.clone(), + }, + )?; - Ok((recipient, FullSpaceOut { - txid: outpoint.txid, - spaceout, - })) + Ok(( + recipient, + FullSpaceOut { + txid: outpoint.txid, + spaceout, + }, + )) } - fn verify_listing_signature(listing: &Listing, outpoint: OutPoint, txout: TxOut) -> anyhow::Result { + fn verify_listing_signature( + listing: &Listing, + outpoint: OutPoint, + txout: TxOut, + ) -> anyhow::Result { let prevouts = Prevouts::One(0, txout.clone()); let addr = SpaceAddress::from_str(&listing.seller)?; @@ -775,18 +886,25 @@ impl SpacesWallet { None => return Err(anyhow::anyhow!("Space not found")), Some(spaceout) => spaceout, }; - if !matches!(spaceout.space.as_ref().unwrap().covenant, Covenant::Transfer { ..}) { + if !matches!( + spaceout.space.as_ref().unwrap().covenant, + Covenant::Transfer { .. } + ) { return Err(anyhow::anyhow!("Space not registered")); } let utxo = match self.internal.get_utxo(space_outpoint) { - None => return Err(anyhow::anyhow!("Wallet does not own a space with outpoint {}", space_outpoint)), - Some(utxo) => utxo + None => { + return Err(anyhow::anyhow!( + "Wallet does not own a space with outpoint {}", + space_outpoint + )) + } + Some(utxo) => utxo, }; let recipient = self.next_unused_space_address(); - let mut sell_psbt = { let mut builder = self .internal @@ -803,10 +921,7 @@ impl SpacesWallet { .manually_selected_only() .sighash(TapSighashType::SinglePlusAnyoneCanPay.into()) .add_utxo(utxo.outpoint)? - .add_recipient( - recipient.script_pubkey(), - total, - ); + .add_recipient(recipient.script_pubkey(), total); builder.finish()? }; @@ -821,10 +936,14 @@ impl SpacesWallet { return Err(anyhow::anyhow!("signing listing psbt failed")); } - let witness = sell_psbt.inputs[0].clone().final_script_witness + let witness = sell_psbt.inputs[0] + .clone() + .final_script_witness .expect("signed listing psbt has a witness"); - let signature = witness.iter().next() + let signature = witness + .iter() + .next() .expect("signed listing must have a single witness item"); Ok(Listing { @@ -926,11 +1045,19 @@ impl SpacesWallet { } } - pub fn get_taproot_keypair(&self, keychain: KeychainKind, derivation_index: u32) -> anyhow::Result { - let secret = match self.internal.get_signers(keychain) + pub fn get_taproot_keypair( + &self, + keychain: KeychainKind, + derivation_index: u32, + ) -> anyhow::Result { + let secret = match self + .internal + .get_signers(keychain) .signers() .iter() - .filter_map(|s| s.descriptor_secret_key()).next() { + .filter_map(|s| s.descriptor_secret_key()) + .next() + { None => return Err(anyhow::anyhow!("No secret key found in signer")), Some(secret) => secret, }; @@ -938,8 +1065,9 @@ impl SpacesWallet { DescriptorSecretKey::XPrv(xprv) => xprv, _ => return Err(anyhow::anyhow!("No xprv found")), }; - let full_path = descriptor_x_key.derivation_path - .child(ChildNumber::Normal { index: derivation_index }); + let full_path = descriptor_x_key.derivation_path.child(ChildNumber::Normal { + index: derivation_index, + }); let ctx = secp256k1::Secp256k1::new(); let xprv = descriptor_x_key.xkey.derive_priv(&ctx, &full_path)?; let keypair = UntweakedKeypair::from_secret_key(&ctx, &xprv.private_key); @@ -976,9 +1104,12 @@ impl SpacesWallet { } let previous_output = psbt.unsigned_tx.input[input_index].previous_output; - let signing_info = - self.get_signing_info(previous_output, &input.witness_utxo.as_ref().unwrap().script_pubkey) - .context("could not retrieve signing info for script")?; + let signing_info = self + .get_signing_info( + previous_output, + &input.witness_utxo.as_ref().unwrap().script_pubkey, + ) + .context("could not retrieve signing info for script")?; if let Some(info) = signing_info { input .proprietary @@ -1059,7 +1190,7 @@ impl SpacesWallet { signature, sighash_type, } - .to_vec(), + .to_vec(), ); witness.push(&signing_info.script); witness.push(&signing_info.control_block.serialize()); @@ -1068,9 +1199,14 @@ impl SpacesWallet { Ok(tx) } - fn get_signing_info(&mut self, previous_output: OutPoint, script: &ScriptBuf) - -> anyhow::Result> { - let db_tx = self.connection.transaction() + fn get_signing_info( + &mut self, + previous_output: OutPoint, + script: &ScriptBuf, + ) -> anyhow::Result> { + let db_tx = self + .connection + .transaction() .context("couldn't create db transaction")?; let info = TxEvent::get_signing_info(&db_tx, previous_output.txid, script)?; Ok(info) @@ -1105,9 +1241,16 @@ impl CoinSelectionAlgorithm for RequiredUtxosOnlyCoinSelectionAlgorithm { /// Creates a dummy revert transaction double spending the foreign input /// to be applied to the wallet's tx graph -fn revert_unconfirmed_bid_tx(bid: &WalletTx, foreign_outpoint: OutPoint) -> Option<(Transaction, u64)> { - let foreign_input = bid.tx_node.input.iter() - .find(|input| input.previous_output == foreign_outpoint)?.clone(); +fn revert_unconfirmed_bid_tx( + bid: &WalletTx, + foreign_outpoint: OutPoint, +) -> Option<(Transaction, u64)> { + let foreign_input = bid + .tx_node + .input + .iter() + .find(|input| input.previous_output == foreign_outpoint)? + .clone(); let op_return_output = bid.tx_node.output.first()?.clone(); if !op_return_output.script_pubkey.is_op_return() { @@ -1121,17 +1264,16 @@ fn revert_unconfirmed_bid_tx(bid: &WalletTx, foreign_outpoint: OutPoint) -> Opti }; let revert_tx_last_seen = match bid.chain_position { ChainPosition::Confirmed { .. } => panic!("must be unconfirmed"), - ChainPosition::Unconfirmed { last_seen } => - last_seen.map(|last_seen| last_seen + 1), + ChainPosition::Unconfirmed { last_seen } => last_seen.map(|last_seen| last_seen + 1), }; Some((revert_tx, revert_tx_last_seen.unwrap_or(1))) } fn is_revert_tx(tx: &WalletTx) -> bool { - !tx.chain_position.is_confirmed() && - tx.tx_node.input.len() == 1 && - tx.tx_node.output.len() == 1 && - tx.tx_node.output[0].script_pubkey.is_op_return() + !tx.chain_position.is_confirmed() + && tx.tx_node.input.len() == 1 + && tx.tx_node.output.len() == 1 + && tx.tx_node.output[0].script_pubkey.is_op_return() } impl SpaceScriptSigningInfo { @@ -1174,7 +1316,7 @@ impl SpaceScriptSigningInfo { 1 + 65 ) as _, ) - .expect("valid weight") + .expect("valid weight") } pub(crate) fn to_vec(&self) -> Vec { @@ -1256,12 +1398,3 @@ impl<'de> Deserialize<'de> for SpaceScriptSigningInfo { deserializer.deserialize_seq(OpenSigningInfoVisitor) } } - -pub fn signed_msg_hash(msg: impl AsRef<[u8]>) -> sha256d::Hash { - let msg_bytes = msg.as_ref(); - let mut engine = sha256d::Hash::engine(); - engine.input(SPACES_SIGNED_MSG_PREFIX); - VarInt::from(msg_bytes.len()).consensus_encode(&mut engine).expect("varint serialization"); - engine.input(msg_bytes); - sha256d::Hash::from_engine(engine) -} diff --git a/wallet/src/nostr.rs b/wallet/src/nostr.rs new file mode 100644 index 0000000..e3baf38 --- /dev/null +++ b/wallet/src/nostr.rs @@ -0,0 +1,132 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Result}; +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use secp256k1::{schnorr::Signature, Keypair, Secp256k1, Signing, Verification, XOnlyPublicKey}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct NostrTag(pub Vec); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NostrEvent { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pubkey: Option, + pub created_at: u64, + pub kind: u32, + pub tags: Vec, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub sig: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub proof: Option, +} + +impl NostrEvent { + pub fn new(kind: u32, content: &str, tags: Vec) -> Self { + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + Self { + id: None, + pubkey: None, + created_at, + kind, + tags, + content: content.to_string(), + sig: None, + proof: None, + } + } + + pub fn space(&self) -> Option { + self.tags + .iter() + .find(|tag| { + if tag.0.len() >= 1 { + tag.0[0] == "space" + } else { + false + } + }) + .map(|tag| tag.0[1].clone()) + } + + pub fn serialize_for_signing(&self) -> Option { + let pubkey = match &self.pubkey { + None => return None, + Some(pubkey) => pubkey, + }; + // Nostr requires a specific serialization format for signing: + // [0, , , , , ] + let serialized = json!([ + 0, + pubkey, + self.created_at, + self.kind, + self.tags, + self.content + ]); + Some(serialized.to_string()) + } + + pub fn compute_id(&self) -> Option { + let serialized = self.serialize_for_signing()?; + + let mut engine = sha256::Hash::engine(); + engine.input(serialized.as_bytes()); + + Some(sha256::Hash::from_engine(engine)) + } + + pub fn verify(&self, ctx: Secp256k1) -> bool { + let pubkey = match &self.pubkey { + None => return false, + Some(pubkey) => pubkey, + }; + let digest = match self.compute_id() { + None => return false, + Some(id) => id, + }; + if self.id.is_some_and(|id| id != digest) { + return false; + } + let sig = match self.sig { + None => return false, + Some(sig) => sig, + }; + let msg = secp256k1::Message::from_digest(digest.to_byte_array()); + match ctx.verify_schnorr(&sig, &msg, pubkey) { + Ok(_) => true, + Err(_) => false, + } + } + + pub fn sign(&mut self, ctx: Secp256k1, keypair: &Keypair) -> Result<()> { + let (pubkey, _) = keypair.x_only_public_key(); + self.pubkey = match self.pubkey { + None => Some(pubkey), + Some(key) => { + if key != pubkey { + return Err(anyhow!("wrong pubkey")); + } else { + Some(pubkey) + } + } + }; + + let digest = self.compute_id().expect("digest"); + if self.id.is_some_and(|id| id != digest) { + return Err(anyhow!("wrong event id")); + } + + self.id = Some(digest.clone()); + let msg_to_sign = secp256k1::Message::from_digest(digest.to_byte_array()); + self.sig = Some(ctx.sign_schnorr(&msg_to_sign, keypair)); + Ok(()) + } +} diff --git a/wallet/src/tx_event.rs b/wallet/src/tx_event.rs index fdb34f0..dfeabd1 100644 --- a/wallet/src/tx_event.rs +++ b/wallet/src/tx_event.rs @@ -1,4 +1,5 @@ use std::{fmt, fmt::Display, str::FromStr}; + use bdk_wallet::{ chain, rusqlite, rusqlite::{ @@ -8,15 +9,18 @@ use bdk_wallet::{ }; use bitcoin::{Amount, OutPoint, ScriptBuf, Transaction, TxOut, Txid}; use serde::{Deserialize, Serialize}; -use protocol::{Covenant, FullSpaceOut}; -use crate::rusqlite_impl::{migrate_schema, Impl}; -use crate::{SpaceScriptSigningInfo, SpacesWallet}; +use spaces_protocol::{Covenant, FullSpaceOut}; + +use crate::{ + rusqlite_impl::{migrate_schema, Impl}, + SpaceScriptSigningInfo, SpacesWallet, +}; #[derive(Clone, Debug)] pub struct TxRecord { pub tx: Transaction, pub events: Vec, - pub txouts: Vec<(OutPoint, TxOut)> + pub txouts: Vec<(OutPoint, TxOut)>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -62,9 +66,9 @@ pub struct OpenEventDetails { #[derive(Debug, Serialize, Deserialize)] pub struct CommitEventDetails { - pub script_pubkey: protocol::Bytes, + pub script_pubkey: spaces_protocol::Bytes, /// [SpaceScriptSigningInfo] in raw format - pub signing_info: protocol::Bytes, + pub signing_info: spaces_protocol::Bytes, } #[derive(Debug, Serialize, Deserialize)] @@ -162,7 +166,10 @@ impl TxEvent { Ok(results) } - pub fn all_bid_txs(db_tx: &rusqlite::Transaction, txid: Txid) -> rusqlite::Result> { + pub fn all_bid_txs( + db_tx: &rusqlite::Transaction, + txid: Txid, + ) -> rusqlite::Result> { let stmt = db_tx.prepare(&format!( "SELECT type, space, previous_spaceout, details FROM {} WHERE type = 'bid' AND txid = ?1", @@ -172,7 +179,11 @@ impl TxEvent { Ok(results.get(0).cloned()) } - pub fn get_signing_info(db_tx: &rusqlite::Transaction, txid: Txid, script_pubkey: &ScriptBuf) -> rusqlite::Result> { + pub fn get_signing_info( + db_tx: &rusqlite::Transaction, + txid: Txid, + script_pubkey: &ScriptBuf, + ) -> rusqlite::Result> { let stmt = db_tx.prepare(&format!( "SELECT type, space, previous_spaceout, details FROM {} WHERE type = 'commit' AND txid = ?1", @@ -183,13 +194,13 @@ impl TxEvent { .expect("could not retrieve signing details from sqlite"); for result in results { let details = result.details.expect("signing details in tx event"); - let details: CommitEventDetails = serde_json::from_value(details) - .expect("signing details"); + let details: CommitEventDetails = + serde_json::from_value(details).expect("signing details"); if details.script_pubkey.as_slice() == script_pubkey.as_bytes() { let raw = details.signing_info.to_vec(); let info = SpaceScriptSigningInfo::from_slice(raw.as_slice()).expect("valid signing info"); - return Ok(Some(info)) + return Ok(Some(info)); } } Ok(None) @@ -221,8 +232,12 @@ impl TxEvent { TxEvent { kind: row.get("type")?, space: row.get("space")?, - previous_spaceout: row.get::<_, Option>>("previous_spaceout")?.map(|x| x.0), - details: row.get::<_, Option>>("details")?.map(|x| x.0), + previous_spaceout: row + .get::<_, Option>>("previous_spaceout")? + .map(|x| x.0), + details: row + .get::<_, Option>>("details")? + .map(|x| x.0), }, )) })?; @@ -291,7 +306,6 @@ impl TxEvent { } } - impl TxRecord { pub fn new(tx: Transaction) -> Self { Self::new_with_events(tx, vec![]) @@ -319,7 +333,10 @@ impl TxRecord { kind: TxEventKind::Transfer, space: Some(space), previous_spaceout: None, - details: Some(serde_json::to_value(TransferEventDetails { script_pubkey: to }).expect("json value")), + details: Some( + serde_json::to_value(TransferEventDetails { script_pubkey: to }) + .expect("json value"), + ), }); } @@ -328,7 +345,10 @@ impl TxRecord { kind: TxEventKind::Renew, space: Some(space), previous_spaceout: None, - details: Some(serde_json::to_value(TransferEventDetails { script_pubkey: to }).expect("json value")), + details: Some( + serde_json::to_value(TransferEventDetails { script_pubkey: to }) + .expect("json value"), + ), }); } @@ -357,7 +377,7 @@ impl TxRecord { recipient_script_pubkey: resolved_address, amount, }) - .expect("json value"), + .expect("json value"), ), }); } @@ -375,10 +395,10 @@ impl TxRecord { previous_spaceout: None, details: Some( serde_json::to_value(CommitEventDetails { - script_pubkey: protocol::Bytes::new(reveal_address.to_bytes()), - signing_info: protocol::Bytes::new(signing_info), + script_pubkey: spaces_protocol::Bytes::new(reveal_address.to_bytes()), + signing_info: spaces_protocol::Bytes::new(signing_info), }) - .expect("json value"), + .expect("json value"), ), }); } @@ -392,7 +412,7 @@ impl TxRecord { serde_json::to_value(OpenEventDetails { initial_bid: initial_bid, }) - .expect("json value"), + .expect("json value"), ), }); } @@ -407,7 +427,7 @@ impl TxRecord { serde_json::to_value(ExecuteEventDetails { n: reveal_input_index, }) - .expect("json value"), + .expect("json value"), ), }); } @@ -420,14 +440,17 @@ impl TxRecord { }; let previous_spaceout = match wallet.is_mine(previous.spaceout.script_pubkey.clone()) { false => Some(previous.outpoint()), - true => None + true => None, }; if previous_spaceout.is_some() { - self.txouts.push((previous.outpoint(), TxOut { - value: previous.spaceout.value, - script_pubkey: previous.spaceout.script_pubkey.clone(), - })) + self.txouts.push(( + previous.outpoint(), + TxOut { + value: previous.spaceout.value, + script_pubkey: previous.spaceout.script_pubkey.clone(), + }, + )) } self.events.push(TxEvent { @@ -439,13 +462,12 @@ impl TxRecord { current_bid: amount, previous_bid: previous_bid, }) - .expect("json value"), + .expect("json value"), ), }); } } - #[cfg(test)] mod tests { use bitcoin::{hashes::Hash, Txid};