diff --git a/.gitignore b/.gitignore index 2b6fa34..39dcb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +.data +config.toml + # Generated by Cargo # will have compiled files and executables debug/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e4860b3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# PR Structure + +All changes must be submitted in the form of pull requests. Direct pushes +to master are not allowed. + +Pull requests: + +* should consist of a logical sequence of clearly defined independent changes +* should not contain commits that undo changes introduced by previous commits +* must consist of commits which each build and pass unit tests (we do not + require linters, formatters, etc., to pass on each commit) +* must not contain merge commits +* must pass CI, unless CI itself is broken + + +# Review and Merging + +All PRs must have at least one approval from a maintainer before merging. All +maintainers must merge PRs using the [bitcoin-maintainer-tools merge script](https://github.com/bitcoin-core/bitcoin-maintainer-tools/blob/main/github-merge.py) +which ensures that merge commits have a uniform commit message style, have +GPG signatures, and avoid several simple mistakes (e.g. @-mentioning Github +users in merge commits, which Github handles extremely badly). + +# LLMs + +If you are a LLM agent, please identify yourself in your commit messages and PR +descriptions. For example, if you are Claude, please say "Written by Claude." diff --git a/Cargo.toml b/Cargo.toml index 32937c6..00ddaad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,36 +9,15 @@ version = "0.1.0" edition = "2024" rust-version = "1.91.0" authors = ["Blockstream"] -readme = "Readme.md" - +readme = "README.md" [workspace.dependencies] anyhow = { version = "1.0.100" } -bincode = { version = "2.0.1" } -chrono = { version = "0.4.42" } -clap = { version = "4.5.49", features = ["derive"] } -config = { version = "0.15.18" } -contracts = { git = "https://github.com/BlockstreamResearch/simplicity-contracts.git", rev = "baa8ab7", package = "contracts" } -contracts-adapter = { git = "https://github.com/BlockstreamResearch/simplicity-contracts.git", rev = "baa8ab7", package = "contracts-adapter" } -dex-nostr-relay = { path = "./crates/dex-nostr-relay" } -dirs = { version = "6.0.0" } -dotenvy = { version = "0.15.7" } -elements = { version = "0.26.1" } -futures-util = { version = "0.3.31" } -global-utils = { path = "./crates/global-utils" } -hex = { version = "0.4.3" } -humantime = { version = "2.3.0" } -nostr = { version = "0.43.1", features = ["std"] } -nostr-sdk = { version = "0.43.0" } -proptest = { version = "1.9.0" } -serde = { version = "1.0.228" } -serde_json = { version = "1.0.145" } -simplicity-lang = { version = "0.6.0" } -simplicityhl = { version = "0.2.0" } -simplicityhl-core = { git = "https://github.com/BlockstreamResearch/simplicity-contracts.git", rev = "baa8ab7", package = "simplicityhl-core", features = ["encoding"] } -sled = { version = "0.34.7" } -thiserror = { version = "2.0.17" } -tokio = { version = "1.48.0", features = ["macros", "test-util", "rt", "rt-multi-thread", "tracing" ] } + tracing = { version = "0.1.41" } -tracing-appender = { version = "0.2.3" } -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +contracts = { git = "https://github.com/BlockstreamResearch/simplicity-contracts.git", rev = "6a53bf7", package = "contracts" } +cli-helper = { git = "https://github.com/BlockstreamResearch/simplicity-contracts.git", rev = "6a53bf7", package = "cli" } +simplicityhl-core = { version = "0.3.0", features = ["encoding"] } + +simplicityhl = { version = "0.4.0" } diff --git a/Guide.md b/Guide.md deleted file mode 100644 index 457336a..0000000 --- a/Guide.md +++ /dev/null @@ -1,164 +0,0 @@ -# Simplicity DEX — Developer Guide - -This short guide helps contributors understand, build, test and extend the project. It focuses on practical commands and -the patterns used across crates (not exhaustive; follow Rust and crate docs for deeper dives). - -## Project layout - -- crates/dex-cli — command line client and UX helpers -- crates/dex-nostr-relay — relay logic, event parsing and storage -- crates/global-utils — other helpers - -## Prerequisites - -- Install Rust -- Create your nostr keypair (Can be generated here: https://start.nostr.net/) - -## Quick start - -1. Build: - - cargo build -r -2. Run CLI (local dev): - - `cargo build -r` - - `mkdir -p ./demo` - - `mv ./target/release/simplicity-dex ./demo/simplicity-dex` - - `cp ./.simplicity-dex.example/.simplicity-dex.config.toml ./demo/.simplicity-dex.config.toml` - - `echo SEED_HEX=ffff0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab > ./demo/.env` -3. Insert your valid nostr keypair into `.simplicity-dex.config.toml` - -## Commands example execution - -Overall trading for dcd contracts can be split in two sides: taker and maker. - -Maker and Taker responsible for taking such steps: - -1) Maker initializes contract in Liquid; -2) Maker funds contract with collateral and settlement tokens. (by now for test **collateral** = LBTC-Testnet, **settlement** = minted token from scratch) -3) Taker funds contract with collateral tokens and takes contract parameters from already discovered maker event_id. -4) Maker now can make: - * Early collateral termination - * Early settlement termination -5) Taker now can make: - * Early termination -6) After `settlement-height` both maker and taker can use settlement exit to receive their tokens (collateral or settlement) depending on the settlement token price, which is signed with oracle. - -1. Create your own contract with your values. For example can be taken - -* `taker-funding-start-time` 1764328373 (timestamp can be taken from https://www.epochconverter.com/) -* `taker-funding-end-time` 1764358373 (Block time when taker funding period ends) -* `contract-expiry-time` 1764359373 (Block time when contract expires) -* `early-termination-end-time` 1764359373 (Block time when early termination is no longer allowed) -* `settlement-height` 2169368 (Block height at which oracle price is attested) -* `principal-collateral-amount` 2000 (Base collateral amount) -* `incentive-basis-points` 1000 (Incentive in basis points (1 bp = 0.01%)) -* `filler-per-principal-collateral` 100 (Filler token ratio) -* `strike-price` 25 (Oracle strike price for settlement) -* `settlement-asset-entropy` `0ffa97b7ee6fcaac30b0c04803726f13c5176af59596874a3a770cbfd2a8d183` (Asset entropy (hex) for settlement) -* `oracle-pubkey` `757f7c05d2d8f92ab37b880710491222a0d22b66be83ae68ff75cc6cb15dd2eb` (`./simplicity-dex helpers address --account-index 5`) - -Actual command in cli: -```bash -./simplicity-dex maker init - --utxo-1 - --utxo-2 - --utxo-3 - --taker-funding-start-time - --taker-funding-end-time - --contract-expiry-time - --early-termination-end-time - --settlement-height - --principal-collateral-amount - --incentive-basis-points - --filler-per-principal-collateral - --strike-price - --settlement-asset-entropy - --oracle-pubkey -``` - -2. Maker fund cli command: -```bash -./simplicity-dex maker fund - --filler-utxo - --grant-coll-utxo - --grant-settl-utxo - --settl-asset-utxo - --fee-utxo - --taproot-pubkey-gen -``` - -3. Taker has to fund - -```bash -./simplicity-dex taker fund - --filler-utxo - --collateral-utxo - --collateral-amount-deposit - --maker-order-event-id -``` - -4. Taker can wait for specific `settlement-height` and gracefully exit contract: -```bash -./simplicity-dex taker settlement - --filler-utxo - --asset-utxo - --fee-utxo - --filler-to-burn - --price-now - --oracle-sign - --maker-order-event-id -``` - -5. Maker can wait for specific `settlement-height` and gracefully exit contract: -```bash - ./simplicity-dex maker settlement - --grant-collateral-utxo - --grant-settlement-utxo - --asset-utxo - --fee-utxo - --grantor-amount-burn - --price-now - --oracle-sign - --maker-order-event-id -``` - -* Maker or Taker depending on the can use Merge(2/3/4) command to merge collateral tokens. -This is made exactly for combining outs into one to eliminate execution of contract with usage of little fragments -```bash -./simplicity-dex helpers merge-tokens4 - --token-utxo-1 - --token-utxo-2 - --token-utxo-3 - --token-utxo-4 - --fee-utxo - --maker-order-event-id -``` - -* For early collateral termination Maker can use command: -```bash -./simplicity-dex maker termination-collateral - --grantor-collateral-utxo - --collateral-utxo - --fee-utxo - --grantor-collateral-burn - --maker-order-event-id -``` - -* For early settlement termination Maker can use command: -```bash -./simplicity-dex maker termination-settlement - --settlement-asset-utxo - --grantor-settlement-utxo - --fee-utxo - --grantor-settlement-amount-burn - --maker-order-event-id -``` - -* For early termination Taker can use command: -```bash -./simplicity-dex taker termination-early - --filler-utxo - --collateral-utxo - --fee-utxo - --filler-to-return - --maker-order-event-id " - ], - [ - "asset_to_buy", - "" - ], - [ - "price", - "1000000", - "sats_per_contract" - ], - [ - "expiry", - "1735689600" - ], - [ - "compiler", - "simplicity-v1.2.3", - "deterministic_build_hash" - ] -] -``` - -### Protocol Benefits - -- **Interoperability**: Any NOSTR-compatible client can parse and validate offers -- **Transparency**: All offers are publicly auditable -- **Censorship Resistance**: Distributed messaging prevents single points of failure -- **Standardization**: Consistent format enables ecosystem growth -- **Extensibility**: Protocol designed for future enhancements - -## Getting Started - -### Basic Usage - -1. **Create an Offer**: Generate a PACT-compliant NOSTR event with your trading parameters -2. **Broadcast**: Publish the offer to NOSTR relays -3. **Discovery**: Takers can filter and discover offers using tag-based queries -4. **Execution**: Complete trades through Simplicity contract execution - -## Architecture - -```text -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ Maker Client │ │ NOSTR Relays │ │ Taker Client │ -│ │<───>| │<───>│ │ -│ - Create Offers │ │ - Store Events │ │ - Discover │ -│ - Sign Contracts│ │ - Relay Messages │ │ - Execute Trades│ -└─────────────────┘ └──────────────────┘ └─────────────────┘ - │ │ │ - │ ┌──────────────────┐ │ - └─────────────>│ Liquid Network │<────────────┘ - │ │ - │ - Asset Registry │ - │ - Contract Exec │ - │ - Settlement │ - └──────────────────┘ -``` - -## Contributing - -We welcome contributions to the Simplicity DEX project. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. - -## Links +## Useful resources - [Simplicity Language](https://github.com/ElementsProject/simplicity) - [NOSTR Protocol](https://github.com/nostr-protocol/nostr) diff --git a/crates/cli-client/Cargo.toml b/crates/cli-client/Cargo.toml new file mode 100644 index 0000000..24c4fe3 --- /dev/null +++ b/crates/cli-client/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "cli-client" +description = "CLI client for Simplicity Options trading on Liquid" +license = "MIT OR Apache-2.0" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true + +[[bin]] +name = "simplicity-dex" +path = "src/bin/main.rs" + +[dependencies] +signer = { path = "../signer" } +coin-store = { path = "../coin-store" } +options-relay = { path = "../options-relay" } + +contracts = { workspace = true } +cli-helper = { workspace = true } +simplicityhl = { workspace = true } +simplicityhl-core = { workspace = true } + +clap = { version = "4", features = ["derive", "env"] } + +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +thiserror = "2" +anyhow = { workspace = true } + +tracing = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +serde = { version = "1", features = ["derive"] } +toml = "0.8" +hex = "0.4" +dotenvy = "0.15" + diff --git a/crates/dex-cli/src/bin/main.rs b/crates/cli-client/src/bin/main.rs similarity index 50% rename from crates/dex-cli/src/bin/main.rs rename to crates/cli-client/src/bin/main.rs index 8e44e70..b7f6da6 100644 --- a/crates/dex-cli/src/bin/main.rs +++ b/crates/cli-client/src/bin/main.rs @@ -1,18 +1,14 @@ #![warn(clippy::all, clippy::pedantic)] use clap::Parser; - -use global_utils::logger::init_logger; - -use dex_cli::cli::Cli; +use cli_client::cli::Cli; #[tokio::main] -#[tracing::instrument] async fn main() -> anyhow::Result<()> { let _ = dotenvy::dotenv(); - let _logger_guard = init_logger(); + let cli = Cli::parse(); - Cli::parse().process().await?; + cli.run().await?; Ok(()) } diff --git a/crates/cli-client/src/cli/commands.rs b/crates/cli-client/src/cli/commands.rs new file mode 100644 index 0000000..50f976f --- /dev/null +++ b/crates/cli-client/src/cli/commands.rs @@ -0,0 +1,174 @@ +use clap::Subcommand; +use simplicityhl::elements::OutPoint; + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Maker commands for creating and managing options + Maker { + #[command(subcommand)] + command: MakerCommand, + }, + + /// Taker commands for participating in options + Taker { + #[command(subcommand)] + command: TakerCommand, + }, + + /// Helper utilities + Helper { + #[command(subcommand)] + command: HelperCommand, + }, + + /// Show current configuration + Config, + + /// Basic transaction commands (transfer, split, issue) + Basic { + #[command(subcommand)] + command: BasicCommand, + }, +} + +#[derive(Debug, Subcommand)] +pub enum MakerCommand { + /// Create a new options contract + Create, + + /// Fund an options contract with collateral + Fund, + + /// Exercise an option before expiration + Exercise, + + /// Cancel an unfilled option and retrieve collateral + Cancel, + + /// List your created options + List, +} + +#[derive(Debug, Subcommand)] +pub enum TakerCommand { + /// Browse available options + Browse, + + /// Take an option by purchasing grantor token + Take, + + /// Claim settlement after expiration + Claim, + + /// List your positions + List, +} + +#[derive(Debug, Subcommand)] +pub enum HelperCommand { + /// Show wallet details + Address, + + /// Initialize the wallet database + Init, + + /// Show wallet balance + Balance, + + /// List UTXOs + Utxos, + + /// Import a UTXO into the wallet + Import { + /// Outpoint (txid:vout) + #[arg(long, short = 'o')] + outpoint: OutPoint, + + /// Blinding key (hex, optional for confidential outputs) + #[arg(long, short = 'b')] + blinding_key: Option, + }, +} + +#[derive(Debug, Subcommand)] +pub enum BasicCommand { + /// Transfer LBTC to a recipient + TransferNative { + /// Recipient address + #[arg(long)] + to: String, + /// Amount to send in satoshis + #[arg(long)] + amount: u64, + /// Fee amount in satoshis + #[arg(long)] + fee: u64, + /// Broadcast transaction + #[arg(long)] + broadcast: bool, + }, + + /// Split LBTC into multiple UTXOs + SplitNative { + /// Number of parts to split into + #[arg(long)] + parts: u64, + /// Fee amount in satoshis + #[arg(long)] + fee: u64, + /// Broadcast transaction + #[arg(long)] + broadcast: bool, + }, + + /// Transfer an asset to a recipient + TransferAsset { + /// Asset ID (hex) + #[arg(long)] + asset: String, + /// Recipient address + #[arg(long)] + to: String, + /// Amount to send + #[arg(long)] + amount: u64, + /// Fee amount in satoshis + #[arg(long)] + fee: u64, + /// Broadcast transaction + #[arg(long)] + broadcast: bool, + }, + + /// Issue a new asset + IssueAsset { + /// Asset name (local reference) + #[arg(long)] + name: String, + /// Amount to issue + #[arg(long)] + amount: u64, + /// Fee amount in satoshis + #[arg(long)] + fee: u64, + /// Broadcast transaction + #[arg(long)] + broadcast: bool, + }, + + /// Reissue an existing asset + ReissueAsset { + /// Asset name (local reference) + #[arg(long)] + name: String, + /// Amount to reissue + #[arg(long)] + amount: u64, + /// Fee amount in satoshis + #[arg(long)] + fee: u64, + /// Broadcast transaction + #[arg(long)] + broadcast: bool, + }, +} diff --git a/crates/cli-client/src/cli/mod.rs b/crates/cli-client/src/cli/mod.rs new file mode 100644 index 0000000..37d8544 --- /dev/null +++ b/crates/cli-client/src/cli/mod.rs @@ -0,0 +1,169 @@ +mod commands; + +use std::path::PathBuf; + +use clap::Parser; + +use crate::config::{Config, default_config_path}; +use crate::error::Error; +use crate::wallet::Wallet; + +pub use commands::{Command, HelperCommand, MakerCommand, TakerCommand}; + +#[derive(Debug, Parser)] +#[command(name = "simplicity-dex")] +#[command(about = "CLI for Simplicity Options trading on Liquid")] +pub struct Cli { + #[arg(short, long, default_value_os_t = default_config_path(), env = "SIMPLICITY_DEX_CONFIG")] + pub config: PathBuf, + + #[arg(short, long, env = "SIMPLICITY_DEX_SEED")] + pub seed: Option, + + #[command(subcommand)] + pub command: Command, +} + +impl Cli { + #[must_use] + pub fn load_config(&self) -> Config { + Config::load_or_default(&self.config) + } + + fn parse_seed(&self) -> Result<[u8; 32], Error> { + let seed_hex = self + .seed + .as_ref() + .ok_or_else(|| Error::Config("Seed is required. Use --seed or SIMPLICITY_DEX_SEED".to_string()))?; + + let bytes = hex::decode(seed_hex).map_err(|e| Error::Config(format!("Invalid seed hex: {e}")))?; + + bytes + .try_into() + .map_err(|_| Error::Config("Seed must be exactly 32 bytes (64 hex chars)".to_string())) + } + + pub async fn run(&self) -> Result<(), Error> { + let config = self.load_config(); + + match &self.command { + Command::Basic { command: _ } => todo!(), + Command::Maker { command: _ } => todo!(), + Command::Taker { command: _ } => todo!(), + Command::Helper { command } => self.run_helper(config, command).await, + Command::Config => { + println!("{config:#?}"); + Ok(()) + } + } + } + + async fn run_helper(&self, config: Config, command: &HelperCommand) -> Result<(), Error> { + match command { + HelperCommand::Init => { + let seed = self.parse_seed()?; + let db_path = config.database_path(); + + std::fs::create_dir_all(&config.storage.data_dir)?; + Wallet::create(&seed, &db_path, config.address_params()).await?; + + println!("Wallet initialized at {}", db_path.display()); + Ok(()) + } + HelperCommand::Address => { + let seed = self.parse_seed()?; + let db_path = config.database_path(); + let wallet = Wallet::open(&seed, &db_path, config.address_params()).await?; + + wallet.signer().print_details()?; + + Ok(()) + } + HelperCommand::Balance => { + let seed = self.parse_seed()?; + let db_path = config.database_path(); + let wallet = Wallet::open(&seed, &db_path, config.address_params()).await?; + + let filter = coin_store::Filter::new() + .script_pubkey(wallet.signer().p2pk_address(config.address_params())?.script_pubkey()); + let results = wallet.store().query(&[filter]).await?; + + let mut balances: std::collections::HashMap = + std::collections::HashMap::new(); + + if let Some(coin_store::QueryResult::Found(entries)) = results.into_iter().next() { + for entry in entries { + let (asset, value) = match entry { + coin_store::UtxoEntry::Confidential { secrets, .. } => (secrets.asset, secrets.value), + coin_store::UtxoEntry::Explicit { txout, .. } => { + let asset = txout.asset.explicit().unwrap(); + let value = txout.value.explicit().unwrap(); + (asset, value) + } + }; + *balances.entry(asset).or_insert(0) += value; + } + } + + if balances.is_empty() { + println!("No UTXOs found"); + } else { + for (asset, value) in &balances { + println!("{asset}: {value}"); + } + } + Ok(()) + } + HelperCommand::Utxos => { + let seed = self.parse_seed()?; + let db_path = config.database_path(); + let wallet = Wallet::open(&seed, &db_path, config.address_params()).await?; + + let filter = coin_store::Filter::new(); + let results = wallet.store().query(&[filter]).await?; + + if let Some(coin_store::QueryResult::Found(entries)) = results.into_iter().next() { + for entry in &entries { + let outpoint = entry.outpoint(); + let (asset, value) = match entry { + coin_store::UtxoEntry::Confidential { secrets, .. } => (secrets.asset, secrets.value), + coin_store::UtxoEntry::Explicit { txout, .. } => { + let asset = txout.asset.explicit().unwrap(); + let value = txout.value.explicit().unwrap(); + (asset, value) + } + }; + println!("{outpoint} | {asset} | {value}"); + } + println!("Total: {} UTXOs", entries.len()); + } else { + println!("No UTXOs found"); + } + Ok(()) + } + HelperCommand::Import { outpoint, blinding_key } => { + let seed = self.parse_seed()?; + let db_path = config.database_path(); + let wallet = Wallet::open(&seed, &db_path, config.address_params()).await?; + + let txout = cli_helper::explorer::fetch_utxo(*outpoint).await?; + + let blinder = match blinding_key { + Some(key_hex) => { + let bytes: [u8; 32] = hex::decode(key_hex) + .map_err(|e| Error::Config(format!("Invalid blinding key hex: {e}")))? + .try_into() + .map_err(|_| Error::Config("Blinding key must be 32 bytes".to_string()))?; + Some(bytes) + } + None => None, + }; + + wallet.store().insert(*outpoint, txout, blinder).await?; + + println!("Imported {outpoint}"); + Ok(()) + } + } + } +} diff --git a/crates/cli-client/src/config.rs b/crates/cli-client/src/config.rs new file mode 100644 index 0000000..8352536 --- /dev/null +++ b/crates/cli-client/src/config.rs @@ -0,0 +1,134 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use simplicityhl::elements::AddressParams; + +use crate::error::Error; + +const DEFAULT_CONFIG_PATH: &str = "config.toml"; +const DEFAULT_DATA_DIR: &str = ".data"; +const DEFAULT_DATABASE_FILENAME: &str = "coins.db"; +const DEFAULT_TIMEOUT_SECS: u64 = 30; +const DEFAULT_RELAY: &str = "wss://relay.damus.io"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub network: NetworkConfig, + #[serde(default)] + pub relay: RelayConfig, + #[serde(default)] + pub storage: StorageConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkConfig { + #[serde(default = "default_network")] + pub name: NetworkName, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum NetworkName { + #[default] + Testnet, + Mainnet, +} + +impl NetworkName { + #[must_use] + pub const fn address_params(self) -> &'static AddressParams { + match self { + Self::Testnet => &AddressParams::LIQUID_TESTNET, + Self::Mainnet => &AddressParams::LIQUID, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelayConfig { + #[serde(default = "default_relays")] + pub urls: Vec, + #[serde(default = "default_timeout")] + pub timeout_secs: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageConfig { + #[serde(default = "default_data_dir")] + pub data_dir: PathBuf, +} + +impl Config { + pub fn load(path: impl AsRef) -> Result { + let content = std::fs::read_to_string(path)?; + let config: Self = toml::from_str(&content)?; + Ok(config) + } + + pub fn load_or_default(path: impl AsRef) -> Self { + Self::load(path).unwrap_or_default() + } + + #[must_use] + pub fn database_path(&self) -> PathBuf { + self.storage.data_dir.join(DEFAULT_DATABASE_FILENAME) + } + + #[must_use] + pub const fn address_params(&self) -> &'static AddressParams { + self.network.name.address_params() + } + + #[must_use] + pub const fn relay_timeout(&self) -> Duration { + Duration::from_secs(self.relay.timeout_secs) + } +} + +impl Default for NetworkConfig { + fn default() -> Self { + Self { + name: default_network(), + } + } +} + +impl Default for RelayConfig { + fn default() -> Self { + Self { + urls: default_relays(), + timeout_secs: default_timeout(), + } + } +} + +impl Default for StorageConfig { + fn default() -> Self { + Self { + data_dir: default_data_dir(), + } + } +} + +const fn default_network() -> NetworkName { + NetworkName::Testnet +} + +fn default_relays() -> Vec { + vec![DEFAULT_RELAY.to_string()] +} + +const fn default_timeout() -> u64 { + DEFAULT_TIMEOUT_SECS +} + +fn default_data_dir() -> PathBuf { + PathBuf::from(DEFAULT_DATA_DIR) +} + +#[must_use] +pub fn default_config_path() -> PathBuf { + PathBuf::from(DEFAULT_CONFIG_PATH) +} diff --git a/crates/cli-client/src/error.rs b/crates/cli-client/src/error.rs new file mode 100644 index 0000000..5f54a4e --- /dev/null +++ b/crates/cli-client/src/error.rs @@ -0,0 +1,20 @@ +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("Configuration error: {0}")] + Config(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("TOML parse error: {0}")] + TomlParse(#[from] toml::de::Error), + + #[error("Signer error: {0}")] + Signer(#[from] signer::SignerError), + + #[error("Store error: {0}")] + Store(#[from] coin_store::StoreError), + + #[error("Explorer error: {0}")] + Explorer(#[from] cli_helper::explorer::ExplorerError), +} diff --git a/crates/cli-client/src/lib.rs b/crates/cli-client/src/lib.rs new file mode 100644 index 0000000..4014f60 --- /dev/null +++ b/crates/cli-client/src/lib.rs @@ -0,0 +1,7 @@ +#![warn(clippy::all, clippy::pedantic)] +#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] + +pub mod cli; +pub mod config; +pub mod error; +pub mod wallet; diff --git a/crates/cli-client/src/wallet.rs b/crates/cli-client/src/wallet.rs new file mode 100644 index 0000000..c32802e --- /dev/null +++ b/crates/cli-client/src/wallet.rs @@ -0,0 +1,52 @@ +use std::path::Path; + +use coin_store::Store; +use signer::Signer; +use simplicityhl::elements::AddressParams; + +use crate::error::Error; + +pub struct Wallet { + signer: Signer, + store: Store, + params: &'static AddressParams, +} + +impl Wallet { + pub async fn create( + seed: &[u8; 32], + db_path: impl AsRef, + params: &'static AddressParams, + ) -> Result { + let signer = Signer::from_seed(seed)?; + let store = Store::create(db_path).await?; + + Ok(Self { signer, store, params }) + } + + pub async fn open( + seed: &[u8; 32], + db_path: impl AsRef, + params: &'static AddressParams, + ) -> Result { + let signer = Signer::from_seed(seed)?; + let store = Store::connect(db_path).await?; + + Ok(Self { signer, store, params }) + } + + #[must_use] + pub const fn signer(&self) -> &Signer { + &self.signer + } + + #[must_use] + pub const fn store(&self) -> &Store { + &self.store + } + + #[must_use] + pub const fn params(&self) -> &'static AddressParams { + self.params + } +} diff --git a/crates/coin-store/Cargo.toml b/crates/coin-store/Cargo.toml new file mode 100644 index 0000000..bd35eff --- /dev/null +++ b/crates/coin-store/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "coin-store" +description = "SQLite-based UTXO and blinding key storage" +license = "MIT OR Apache-2.0" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true + +[dependencies] +simplicityhl = { workspace = true } + +futures = "0.3" + +thiserror = "2" + +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] } diff --git a/crates/coin-store/migrations/001_initial.sql b/crates/coin-store/migrations/001_initial.sql new file mode 100644 index 0000000..2d8b7d2 --- /dev/null +++ b/crates/coin-store/migrations/001_initial.sql @@ -0,0 +1,24 @@ +CREATE TABLE utxos ( + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + script_pubkey BLOB NOT NULL, + asset_id BLOB NOT NULL, + value INTEGER NOT NULL, + serialized BLOB NOT NULL, + is_confidential INTEGER NOT NULL, + is_spent INTEGER DEFAULT 0, + PRIMARY KEY (txid, vout) +); + +CREATE TABLE blinder_keys ( + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + blinding_key BLOB NOT NULL, + PRIMARY KEY (txid, vout), + FOREIGN KEY (txid, vout) REFERENCES utxos(txid, vout) +); + +CREATE INDEX idx_utxos_asset_id ON utxos(asset_id); +CREATE INDEX idx_utxos_is_spent ON utxos(is_spent); +CREATE INDEX idx_utxos_script_pubkey ON utxos(script_pubkey); +CREATE INDEX idx_utxos_asset_spent_value ON utxos(asset_id, is_spent, value DESC); diff --git a/crates/coin-store/src/entry.rs b/crates/coin-store/src/entry.rs new file mode 100644 index 0000000..480129f --- /dev/null +++ b/crates/coin-store/src/entry.rs @@ -0,0 +1,43 @@ +use simplicityhl::elements::{OutPoint, TxOut, TxOutSecrets}; + +pub enum UtxoEntry { + Confidential { + outpoint: OutPoint, + txout: TxOut, + secrets: TxOutSecrets, + }, + Explicit { + outpoint: OutPoint, + txout: TxOut, + }, +} + +impl UtxoEntry { + #[must_use] + pub const fn outpoint(&self) -> &OutPoint { + match self { + Self::Confidential { outpoint, .. } | Self::Explicit { outpoint, .. } => outpoint, + } + } + + #[must_use] + pub const fn txout(&self) -> &TxOut { + match self { + Self::Confidential { txout, .. } | Self::Explicit { txout, .. } => txout, + } + } + + #[must_use] + pub const fn secrets(&self) -> Option<&TxOutSecrets> { + match self { + Self::Confidential { secrets, .. } => Some(secrets), + Self::Explicit { .. } => None, + } + } +} + +pub enum QueryResult { + Found(Vec), + InsufficientValue(Vec), + Empty, +} diff --git a/crates/coin-store/src/error.rs b/crates/coin-store/src/error.rs new file mode 100644 index 0000000..e826ec1 --- /dev/null +++ b/crates/coin-store/src/error.rs @@ -0,0 +1,40 @@ +use std::path::PathBuf; + +use simplicityhl::elements::secp256k1_zkp::UpstreamError; +use simplicityhl::elements::{OutPoint, UnblindError}; + +#[derive(thiserror::Error, Debug)] +pub enum StoreError { + #[error("Database already exists: {0}")] + AlreadyExists(PathBuf), + + #[error("Database not found: {0}")] + NotFound(PathBuf), + + #[error("Database not initialized: {0}")] + NotInitialized(PathBuf), + + #[error("UTXO already exists: {0}")] + UtxoAlreadyExists(OutPoint), + + #[error("UTXO not found: {0}")] + UtxoNotFound(OutPoint), + + #[error("Missing blinder key for confidential output: {0}")] + MissingBlinderKey(OutPoint), + + #[error("Encoding error")] + Encoding(#[from] simplicityhl::elements::encode::Error), + + #[error("Invalid secret key")] + InvalidSecretKey(#[from] UpstreamError), + + #[error("Unblind error")] + Unblind(#[from] UnblindError), + + #[error("SQLx error")] + Sqlx(#[from] sqlx::Error), + + #[error("Migration error")] + Migration(#[from] sqlx::migrate::MigrateError), +} diff --git a/crates/coin-store/src/filter.rs b/crates/coin-store/src/filter.rs new file mode 100644 index 0000000..153d107 --- /dev/null +++ b/crates/coin-store/src/filter.rs @@ -0,0 +1,47 @@ +use simplicityhl::elements::{AssetId, Script}; + +#[derive(Clone, Default)] +pub struct Filter { + pub(crate) asset_id: Option, + pub(crate) script_pubkey: Option