diff --git a/.gitignore b/.gitignore index 001b8ac..2b6fa34 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,9 @@ dist/ **/.DS_Store # Logs -logs/* \ No newline at end of file +logs/* + +/.simplicity-dex.config.toml +/.cache +taker/ +simplicity-dex \ No newline at end of file diff --git a/.simplicity-dex.example/.simplicity-dex.config.toml b/.simplicity-dex.example/.simplicity-dex.config.toml new file mode 100644 index 0000000..2b86dde --- /dev/null +++ b/.simplicity-dex.example/.simplicity-dex.config.toml @@ -0,0 +1,6 @@ +# Keypair can be generated here: https://start.nostr.net/ +nostr_keypair = "keypair..." +relays = [ + "wss://relay.damus.io", + "wss://nostr.wine/", +] diff --git a/.simplicity-dex.example/keypair.txt b/.simplicity-dex.example/keypair.txt deleted file mode 100644 index d130f25..0000000 --- a/.simplicity-dex.example/keypair.txt +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/.simplicity-dex.example/relays.txt b/.simplicity-dex.example/relays.txt deleted file mode 100644 index 6a6b8a4..0000000 --- a/.simplicity-dex.example/relays.txt +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 10e1ee0..32937c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,23 +9,36 @@ 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"] } -dirs = {version = "6.0.0"} +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" } +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" } -dex-nostr-relay = { path = "./crates/dex-nostr-relay"} -serde = { version = "1.0.228", features = ["derive"] } +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"] } +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"] } -url = { version = "2.5.7" } diff --git a/Guide.md b/Guide.md new file mode 100644 index 0000000..457336a --- /dev/null +++ b/Guide.md @@ -0,0 +1,164 @@ +# 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"] + [ + "asset_to_sell", + "" + ], + [ + "asset_to_buy", + "" + ], + [ + "price", + "1000000", + "sats_per_contract" + ], + [ + "expiry", + "1735689600" + ], + [ + "compiler", + "simplicity-v1.2.3", + "deterministic_build_hash" + ] ] ``` @@ -99,4 +121,5 @@ This project is licensed under the MIT License - see the LICENSE file for detail ## Disclaimer -This software is experimental and should be used with caution. Always verify contract code and understand the risks before trading. +This software is experimental and should be used with caution. Always verify contract code and understand the risks +before trading. \ No newline at end of file diff --git a/Readme.md b/Readme.md deleted file mode 100644 index 3381aa6..0000000 --- a/Readme.md +++ /dev/null @@ -1,125 +0,0 @@ -# Simplicity DEX - -A distributed exchange built on the NOSTR protocol, leveraging Simplicity smart contracts and the PACT (PACT for -Auditable Contract Transactions) messaging protocol. - -## Overview - -Simplicity DEX is a decentralized exchange that combines the power of Simplicity smart contracts with the distributed -messaging capabilities of NOSTR. By utilizing the PACT protocol, we enable secure, auditable, and transparent trading of -digital assets without relying on centralized intermediaries. - -## Key Features - -- **Decentralized Architecture**: Built on NOSTR for censorship-resistant, distributed messaging -- **Simplicity Smart Contracts**: Leveraging Bitcoin's Simplicity language for provably secure contract execution -- **PACT Protocol**: Standardized format for auditable contract transactions -- **Open Ecosystem**: Compatible with any NOSTR client for maximum interoperability -- **Maker Identity Registry**: On-chain reputation system for market makers - -## DEX Messaging Protocol - -The core of our DEX is the **PACT (PACT for Auditable Contract Transactions)** protocol, which defines the format of -trading offers. This protocol is fully adapted to be compatible with the NOSTR event structure. - -### Offer Structure - -A PACT offer is implemented as a standard NOSTR event with kind `30078` (non-standard, ephemeral event kind for DEX -offers). The event structure maps to PACT requirements as follows: - -| NOSTR Field | PACT Field | Data Type | Required | Description | -|---------------|---------------|-----------------------|----------|-------------------------------------------------------------------------------------------------------------------| -| `id` | Event ID | string (64-char hex) | Yes | SHA-256 hash of canonical serialized event data (excluding `sig`). Serves as unique, content-addressed identifier | -| `pubkey` | Maker Key | string (64-char hex) | Yes | 32-byte x-only Schnorr public key of market maker. Must be registered in on-chain Maker Identity Registry | -| `created_at` | Timestamp | integer | Yes | Unix timestamp (seconds) when offer was created | -| `description` | Description | string | No | Human-readable description of instrument and complex terms | -| `kind` | Event Type | integer | Yes | Event type identifier. Value `1` reserved for standard offers. Enables future protocol extensions | -| `tags` | Metadata | array of arrays | Yes | Structured machine-readable metadata for filtering and discovery | -| `content` | Contract Code | string | Yes | Stringified JSON containing full Simplicity contract code | -| `sig` | Signature | string (128-char hex) | Yes | 64-byte Schnorr signature proving authenticity and integrity | - -### Tag Examples - -The `tags` field contains structured metadata as key-value pairs: - -```json -[ - [ - "asset_to_sell", - "" - ], - [ - "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 - -- [Simplicity Language](https://github.com/ElementsProject/simplicity) -- [NOSTR Protocol](https://github.com/nostr-protocol/nostr) -- [Liquid Network](https://liquid.net/) - -## Disclaimer - -This software is experimental and should be used with caution. Always verify contract code and understand the risks -before trading. diff --git a/crates/dex-cli/Cargo.toml b/crates/dex-cli/Cargo.toml index 6cc0d2e..f46104d 100644 --- a/crates/dex-cli/Cargo.toml +++ b/crates/dex-cli/Cargo.toml @@ -1,18 +1,39 @@ [package] -name = "simplicity-dex" +name = "dex-cli" version = "0.1.0" edition = "2024" +description = "Simplicity helper CLI for Liquid testnet" +license = "MIT OR Apache-2.0" +readme = "README.md" +publish = false + + +[[bin]] +name = "simplicity-dex" +path = "src/bin/main.rs" [dependencies] anyhow = { workspace = true } -nostr = { workspace = true } +bincode = { workspace = true } +clap = { workspace = true, features = ["env"] } +config = { workspace = true } +contracts = { workspace = true } +contracts-adapter = { workspace = true } +dex-nostr-relay = { workspace = true } +dotenvy = { workspace = true } +elements = { workspace = true } global-utils = { workspace = true } -futures-util = { workspace = true } +hex = { workspace = true } +humantime = { workspace = true } +nostr = { workspace = true } +proptest = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } +simplicity-lang = { workspace = true } +simplicityhl = { workspace = true } +simplicityhl-core = { workspace = true } +sled = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true } -clap = { workspace = true } -dirs = { workspace = true } tracing = { workspace = true } -thiserror = { workspace = true } -dex-nostr-relay = { workspace = true } diff --git a/crates/dex-cli/src/main.rs b/crates/dex-cli/src/bin/main.rs similarity index 70% rename from crates/dex-cli/src/main.rs rename to crates/dex-cli/src/bin/main.rs index bdb09a3..8e44e70 100644 --- a/crates/dex-cli/src/main.rs +++ b/crates/dex-cli/src/bin/main.rs @@ -1,12 +1,15 @@ +#![warn(clippy::all, clippy::pedantic)] + use clap::Parser; use global_utils::logger::init_logger; -use simplicity_dex::cli::Cli; +use dex_cli::cli::Cli; #[tokio::main] #[tracing::instrument] async fn main() -> anyhow::Result<()> { + let _ = dotenvy::dotenv(); let _logger_guard = init_logger(); Cli::parse().process().await?; diff --git a/crates/dex-cli/src/cli.rs b/crates/dex-cli/src/cli.rs deleted file mode 100644 index 78ac270..0000000 --- a/crates/dex-cli/src/cli.rs +++ /dev/null @@ -1,167 +0,0 @@ -use crate::utils::{ - DEFAULT_CLIENT_TIMEOUT_SECS, check_file_existence, default_key_path, default_relays_path, get_valid_key_from_file, - get_valid_urls_from_file, write_into_stdout, -}; -use clap::{Parser, Subcommand}; -use nostr::{EventId, PublicKey}; - -use dex_nostr_relay::relay_client::ClientConfig; -use dex_nostr_relay::relay_processor::{OrderPlaceEventTags, OrderReplyEventTags, RelayProcessor}; -use std::path::PathBuf; -use std::time::Duration; -use tracing::instrument; - -#[derive(Parser)] -pub struct Cli { - /// Specify private key for posting authorized events on Nostr Relay - #[arg( - short = 'k', - long, - value_parser = check_file_existence - )] - key_path: Option, - /// Specify file with list of relays to use - #[arg( - short = 'r', - long, - value_parser = check_file_existence - )] - relays_path: Option, - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - #[command(about = "Commands collection for the maker role")] - Maker { - #[command(subcommand)] - action: MakerCommands, - }, - #[command(about = "Commands collection for the taker role")] - Taker { - #[command(subcommand)] - action: TakerCommands, - }, - #[command(about = "Get replies for a specific order by its ID [no authentication required]")] - GetOrderReplies { - #[arg(short = 'i', long)] - event_id: EventId, - }, - #[command(about = "List available orders from relays [no authentication required]")] - ListOrders, - #[command(about = "Get events by its ID [no authentication required]")] - GetEventsById { - #[arg(short = 'i', long)] - event_id: EventId, - }, -} - -#[derive(Debug, Subcommand)] -enum MakerCommands { - #[command(about = "Create order as Maker on Relays specified [authentication required]")] - CreateOrder { - #[arg(short = 's', long, default_value = "")] - asset_to_sell: String, - #[arg(short = 'b', long, default_value = "")] - asset_to_buy: String, - #[arg(short = 'p', long, default_value = "0")] - price: u64, - #[arg(short = 'e', long, default_value = "0")] - expiry: u64, - #[arg(short = 'c', long, default_value = "")] - compiler_name: String, - #[arg(short = 's', long, default_value = "")] - compiler_build_hash: String, - }, -} - -#[derive(Debug, Subcommand)] -enum TakerCommands { - #[command(about = "Reply order as Taker on Relays specified [authentication required]")] - ReplyOrder { - #[arg(short = 'i', long)] - maker_event_id: EventId, - #[arg(short = 'p', long, help = " Pubkey in bech32 or hex format")] - maker_pubkey: PublicKey, - #[arg(short = 't', long, help = "Txid from funding transaction step", required = false)] - tx_id: String, - }, -} - -impl Cli { - #[instrument(skip(self))] - pub async fn process(self) -> crate::error::Result<()> { - let keys = { - match get_valid_key_from_file(&self.key_path.unwrap_or(default_key_path())) { - Ok(keys) => Some(keys), - Err(err) => { - tracing::warn!("Failed to parse key, {err}"); - None - } - } - }; - let relays_urls = get_valid_urls_from_file(&self.relays_path.unwrap_or(default_relays_path()))?; - let relay_processor = RelayProcessor::try_from_config( - relays_urls, - keys, - ClientConfig { - timeout: Duration::from_secs(DEFAULT_CLIENT_TIMEOUT_SECS), - }, - ) - .await?; - - let msg = { - match self.command { - Command::Maker { action } => match action { - MakerCommands::CreateOrder { - asset_to_sell, - asset_to_buy, - price, - expiry, - compiler_name, - compiler_build_hash, - } => { - let res = relay_processor - .place_order(OrderPlaceEventTags { - asset_to_sell, - asset_to_buy, - price, - expiry, - compiler_name, - compiler_build_hash, - }) - .await?; - format!("Creating order result: {res:#?}") - } - }, - Command::Taker { action } => match action { - TakerCommands::ReplyOrder { - maker_event_id, - maker_pubkey, - tx_id, - } => { - let res = relay_processor - .reply_order(maker_event_id, maker_pubkey, OrderReplyEventTags { tx_id }) - .await?; - format!("Replying order result: {res:#?}") - } - }, - Command::GetOrderReplies { event_id } => { - let res = relay_processor.get_order_replies(event_id).await?; - format!("Order '{event_id}' replies: {res:#?}") - } - Command::ListOrders => { - let res = relay_processor.list_orders().await?; - format!("List of available orders: {res:#?}") - } - Command::GetEventsById { event_id } => { - let res = relay_processor.get_events_by_id(event_id).await?; - format!("List of available events: {res:#?}") - } - } - }; - write_into_stdout(msg)?; - Ok(()) - } -} diff --git a/crates/dex-cli/src/cli/dex.rs b/crates/dex-cli/src/cli/dex.rs new file mode 100644 index 0000000..b2c4d4f --- /dev/null +++ b/crates/dex-cli/src/cli/dex.rs @@ -0,0 +1,78 @@ +use clap::Subcommand; +use nostr::{EventId, Timestamp}; + +#[derive(Debug, Subcommand)] +pub enum DexCommands { + #[command(about = "Fetch replies for a specific order event from Nostr relays [no authentication required]")] + GetOrderReplies { + #[arg(short = 'i', long)] + event_id: EventId, + }, + #[command(about = "List all currently available orders discovered on Nostr relays [no authentication required]")] + ListOrders { + /// Comma-separated list of author public keys to filter by (hex or npub) + #[arg(long = "authors", value_delimiter = ',')] + authors: Option>, + #[command(subcommand)] + time_to_filter: Option, + /// Maximum number of orders to return + #[arg(long = "limit")] + limit: Option, + }, + #[command(about = "Import order parameters from a Maker order Nostr event [no authentication required]")] + ImportParams { + #[arg(short = 'i', long)] + event_id: EventId, + }, + #[command(about = "Fetch an arbitrary Nostr event by its ID [no authentication required]")] + GetEventsById { + #[arg(short = 'i', long)] + event_id: EventId, + }, + #[command(about = "Fetch a single order by its event ID from Nostr relays [no authentication required]")] + GetOrderById { + #[arg(short = 'i', long)] + event_id: EventId, + }, +} + +#[derive(Debug, Subcommand)] +pub enum TimeOptionArgs { + /// Filter events from the last duration (e.g., "1h", "30m", "7d") + #[command(name = "duration")] + Duration { + #[arg(value_name = "DURATION")] + value: humantime::Duration, + }, + /// Filter events by timestamp range + #[command(name = "timestamp")] + Timestamp { + /// Filter events since this Unix timestamp + #[arg(long = "since")] + since: Option, + /// Filter events until this Unix timestamp + #[arg(long = "until")] + until: Option, + }, +} + +impl TimeOptionArgs { + #[must_use] + pub fn compute_since(&self) -> Option { + match self { + TimeOptionArgs::Duration { value } => { + let now = Timestamp::now().as_u64(); + Some(now.saturating_sub(value.as_secs())) + } + TimeOptionArgs::Timestamp { since, .. } => *since, + } + } + + #[must_use] + pub fn compute_until(&self) -> Option { + match self { + TimeOptionArgs::Duration { .. } => None, + TimeOptionArgs::Timestamp { until, .. } => *until, + } + } +} diff --git a/crates/dex-cli/src/cli/helper.rs b/crates/dex-cli/src/cli/helper.rs new file mode 100644 index 0000000..0076b58 --- /dev/null +++ b/crates/dex-cli/src/cli/helper.rs @@ -0,0 +1,144 @@ +use crate::cli::CommonOrderOptions; +use clap::Subcommand; +use nostr::EventId; +use simplicity::elements::OutPoint; + +#[derive(Debug, Subcommand)] +pub enum HelperCommands { + #[command(about = "Display a test P2PK address for the given account index [testing only]")] + Address { + /// Account index to use for change address + #[arg(long = "account-index", default_value_t = 0)] + account_index: u32, + }, + #[command(about = "Issue new test tokens backed by LBTC for settlement testing [testing only]")] + Faucet { + /// Transaction id (hex) and output index (vout) of the LBTC UTXO used to pay fees and issue the asset + #[arg(long = "fee-utxo")] + fee_utxo_outpoint: OutPoint, + /// Asset name + #[arg(long = "asset-name")] + asset_name: String, + /// Amount to issue of the asset in its satoshi units + #[arg(long = "issue-sats", default_value_t = 1000000000000000)] + issue_amount: u64, + /// Miner fee in satoshis (LBTC). A separate fee output is added. + #[arg(long = "fee-sats", default_value_t = 500)] + fee_amount: u64, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command(about = "Reissue additional units of an already created test asset [testing only]")] + MintTokens { + /// Transaction id (hex) and output index (vout) of the REISSUANCE ASSET UTXO you will spend + #[arg(long = "reissue-asset-utxo")] + reissue_asset_outpoint: OutPoint, + /// Transaction id (hex) and output index (vout) of the LBTC UTXO used to pay fees and reissue the asset + #[arg(long = "fee-utxo")] + fee_utxo_outpoint: OutPoint, + /// Asset name + #[arg(long = "asset-name")] + asset_name: String, + /// Amount to reissue of the asset in its satoshi units + #[arg(long = "reissue-sats", default_value_t = 1000000000000000)] + reissue_amount: u64, + /// Miner fee in satoshis (LBTC). A separate fee output is added. + #[arg(long = "fee-sats", default_value_t = 500)] + fee_amount: u64, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command(about = "Split a single LBTC UTXO into three outputs of equal value [testing only]")] + SplitNativeThree { + #[arg(long = "split-amount")] + split_amount: u64, + /// Fee utxo + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + #[arg(long = "fee-amount", default_value_t = 150)] + fee_amount: u64, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command(about = "Sign oracle message with keypair [testing only]")] + OracleSignature { + /// Price at current block height + #[arg(long = "price-at-current-block-height")] + price_at_current_block_height: u64, + /// Settlement height + #[arg(long = "settlement-height")] + settlement_height: u32, + /// Oracle account index to derive key from `SEED_HEX` + #[arg(long = "oracle-account-index")] + oracle_account_index: u32, + }, + #[command(about = "Merge 2 token UTXOs into 1")] + MergeTokens2 { + /// First token UTXO + #[arg(long = "token-utxo-1")] + token_utxo_1: OutPoint, + /// Second token UTXO + #[arg(long = "token-utxo-2")] + token_utxo_2: OutPoint, + /// Fee UTXO + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the final settlement transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// `EventId` of the Maker\'s original order event on Nostr + #[arg(short = 'i', long)] + maker_order_event_id: EventId, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command(about = "Merge 3 token UTXOs into 1")] + MergeTokens3 { + /// First token UTXO + #[arg(long = "token-utxo-1")] + token_utxo_1: OutPoint, + /// Second token UTXO + #[arg(long = "token-utxo-2")] + token_utxo_2: OutPoint, + /// Third token UTXO + #[arg(long = "token-utxo-3")] + token_utxo_3: OutPoint, + /// Fee UTXO + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the final settlement transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// `EventId` of the Maker\'s original order event on Nostr + #[arg(short = 'i', long)] + maker_order_event_id: EventId, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command(about = "Merge 4 token UTXOs into 1")] + MergeTokens4 { + /// First token UTXO + #[arg(long = "token-utxo-1")] + token_utxo_1: OutPoint, + /// Second token UTXO + #[arg(long = "token-utxo-2")] + token_utxo_2: OutPoint, + /// Third token UTXO + #[arg(long = "token-utxo-3")] + token_utxo_3: OutPoint, + /// Fourth token UTXO + #[arg(long = "token-utxo-4")] + token_utxo_4: OutPoint, + /// Fee UTXO + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the final settlement transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// `EventId` of the Maker\'s original order event on Nostr + #[arg(short = 'i', long)] + maker_order_event_id: EventId, + #[command(flatten)] + common_options: CommonOrderOptions, + }, +} diff --git a/crates/dex-cli/src/cli/maker.rs b/crates/dex-cli/src/cli/maker.rs new file mode 100644 index 0000000..2712687 --- /dev/null +++ b/crates/dex-cli/src/cli/maker.rs @@ -0,0 +1,146 @@ +use crate::cli::CommonOrderOptions; +use crate::common::InitOrderArgs; +use clap::Subcommand; +use nostr::EventId; +use simplicity::elements::OutPoint; + +#[derive(Debug, Subcommand)] +pub enum MakerCommands { + #[command( + about = "Mint three DCD token types and create an initial Maker offer for a Taker", + long_about = "Mint three distinct DCD token types and initialize a Maker offer. \ + These tokens represent the Maker/Taker claims on collateral and settlement assets \ + and are used to manage the contract lifecycle (funding, early termination, settlement).", + name = "init" + )] + InitOrder { + /// LBTC UTXO used to fund issuance fees and the first DCD token + #[arg(long = "utxo-1")] + first_lbtc_utxo: OutPoint, + /// LBTC UTXO used to fund issuance fees and the second DCD token + #[arg(long = "utxo-2")] + second_lbtc_utxo: OutPoint, + /// LBTC UTXO used to fund issuance fees and the third DCD token + #[arg(long = "utxo-3")] + third_lbtc_utxo: OutPoint, + #[command(flatten)] + init_order_args: InitOrderArgs, + /// Miner fee in satoshis (LBTC) for the init order transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command( + about = "Fund a DCD offer by locking Maker tokens into the contract and publish the order on relays [authentication required]" + )] + Fund { + /// UTXO containing Maker filler tokens to be locked into the DCD contract + #[arg(long = "filler-utxo")] + filler_token_utxo: OutPoint, + /// UTXO containing Maker grantor collateral tokens to be locked or burned + #[arg(long = "grant-coll-utxo")] + grantor_collateral_token_utxo: OutPoint, + /// UTXO containing Maker grantor settlement tokens to be locked or burned + #[arg(long = "grant-settl-utxo")] + grantor_settlement_token_utxo: OutPoint, + /// UTXO providing the settlement asset (e.g. LBTC) for the DCD contract + #[arg(long = "settl-asset-utxo")] + settlement_asset_utxo: OutPoint, + /// UTXO used to pay miner fees for the Maker funding transaction + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the Maker funding transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// Taproot internal pubkey (hex) used to derive the contract output address + #[arg(long = "taproot-pubkey-gen")] + dcd_taproot_pubkey_gen: String, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command( + about = "Withdraw Maker collateral early by burning grantor collateral tokens (DCD early termination leg)" + )] + TerminationCollateral { + /// UTXO containing grantor collateral tokens to be burned for early termination + #[arg(long = "grantor-collateral-utxo")] + grantor_collateral_token_utxo: OutPoint, + /// UTXO containing the collateral asset (e.g. LBTC) to be withdrawn by the Maker + #[arg(long = "collateral-utxo")] + collateral_token_utxo: OutPoint, + /// UTXO used to pay miner fees for the early-termination collateral transaction + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the early-termination collateral transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// Amount of grantor collateral tokens (in satoshis) to burn for early termination + #[arg(long = "grantor-collateral-burn")] + grantor_collateral_amount_to_burn: u64, + /// `EventId` of the Maker\'s original order event on Nostr + #[arg(short = 'i', long)] + maker_order_event_id: EventId, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command( + about = "Withdraw Maker settlement asset early by burning grantor settlement tokens (DCD early termination leg)" + )] + TerminationSettlement { + /// UTXO providing the settlement asset (e.g. LBTC) to be withdrawn by the Maker + #[arg(long = "settlement-asset-utxo")] + settlement_asset_utxo: OutPoint, + /// UTXO containing grantor settlement tokens to be burned for early termination + #[arg(long = "grantor-settlement-utxo")] + grantor_settlement_token_utxo: OutPoint, + /// UTXO used to pay miner fees for the early-termination settlement transaction + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the early-termination settlement transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// Amount of grantor settlement tokens (in satoshis) to burn for early termination + #[arg(long = "grantor-settlement-amount-burn")] + grantor_settlement_amount_to_burn: u64, + /// `EventId` of the Maker\'s original order event on Nostr + #[arg(short = 'i', long)] + maker_order_event_id: EventId, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command( + about = "Settle the Maker side of the DCD at maturity using an oracle price to decide between collateral or settlement asset" + )] + Settlement { + /// UTXO containing grantor collateral tokens used in final settlement + #[arg(long = "grant-collateral-utxo")] + grantor_collateral_token_utxo: OutPoint, + /// UTXO containing grantor settlement tokens used in final settlement + #[arg(long = "grant-settlement-utxo")] + grantor_settlement_token_utxo: OutPoint, + /// UTXO providing the asset (collateral or settlement) paid out to the Maker at maturity + #[arg(long = "asset-utxo")] + asset_utxo: OutPoint, + /// UTXO used to pay miner fees for the final Maker settlement transaction + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the final settlement transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// Amount of grantor (settlement and collateral) tokens (in satoshis) to burn during settlement step + #[arg(long = "grantor-amount-burn")] + grantor_amount_to_burn: u64, + /// Oracle price at current block height used for settlement decision + #[arg(long = "price-now")] + price_at_current_block_height: u64, + /// Schnorr signature produced by the oracle over the published price + #[arg(long = "oracle-sign")] + oracle_signature: String, + /// `EventId` of the Maker\'s original order event on Nostr + #[arg(short = 'i', long)] + maker_order_event_id: EventId, + #[command(flatten)] + common_options: CommonOrderOptions, + }, +} diff --git a/crates/dex-cli/src/cli/mod.rs b/crates/dex-cli/src/cli/mod.rs new file mode 100644 index 0000000..b060798 --- /dev/null +++ b/crates/dex-cli/src/cli/mod.rs @@ -0,0 +1,10 @@ +mod dex; +mod helper; +mod maker; +mod processor; +mod taker; + +pub use dex::*; +pub use maker::*; +pub use processor::*; +pub use taker::*; diff --git a/crates/dex-cli/src/cli/processor.rs b/crates/dex-cli/src/cli/processor.rs new file mode 100644 index 0000000..b645cc8 --- /dev/null +++ b/crates/dex-cli/src/cli/processor.rs @@ -0,0 +1,1129 @@ +use crate::cli::helper::HelperCommands; +use crate::cli::{DexCommands, MakerCommands, TakerCommands}; +use crate::common::config::AggregatedConfig; +use crate::common::{DEFAULT_CLIENT_TIMEOUT_SECS, InitOrderArgs, write_into_stdout}; +use crate::contract_handlers; +use clap::{Parser, Subcommand}; +use dex_nostr_relay::relay_client::ClientConfig; +use dex_nostr_relay::relay_processor::{ListOrdersEventFilter, RelayProcessor}; +use dex_nostr_relay::types::ReplyOption; +use elements::hex::ToHex; +use nostr::{EventId, Keys, RelayUrl, Timestamp}; +use simplicity::elements::OutPoint; +use std::path::PathBuf; +use std::time::Duration; +use tracing::instrument; + +pub(crate) const DEFAULT_CONFIG_PATH: &str = ".simplicity-dex.config.toml"; + +#[derive(Parser)] +pub struct Cli { + /// Private key used to authenticate and sign events on the Nostr relays (hex or bech32) + #[arg(short = 'k', long, env = "DEX_NOSTR_KEYPAIR")] + pub(crate) nostr_key: Option, + + /// List of Nostr relay URLs to connect to (e.g. ) + #[arg(short = 'r', long, value_delimiter = ',', env = "DEX_NOSTR_RELAYS")] + pub(crate) relays_list: Option>, + + /// Path to a config file containing the list of relays and(or) nostr keypair to use + #[arg(short = 'c', long, default_value = DEFAULT_CONFIG_PATH, env = "DEX_NOSTR_CONFIG_PATH")] + pub(crate) nostr_config_path: PathBuf, + + /// Command to execute + #[command(subcommand)] + command: Command, +} + +/// Common CLI options shared between maker/taker commands that build and (optionally) broadcast a tx. +#[derive(Debug, Clone, Copy, Parser)] +pub struct CommonOrderOptions { + /// Account index used to derive internal/change addresses from the wallet + #[arg(long = "account-index", default_value_t = 0)] + pub account_index: u32, + /// When set, the transaction would be only printed, otherwise it'd ve broadcasted the built transaction via Esplora + #[arg(long = "offline")] + pub is_offline: bool, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Maker-side commands for creating and managing DCD orders + #[command()] + Maker { + #[command(subcommand)] + action: MakerCommands, + }, + + /// Taker-side commands for funding and managing DCD positions + #[command()] + Taker { + #[command(subcommand)] + action: TakerCommands, + }, + /// Dex commands that is related with nostr and interaction with it + #[command()] + Dex { + #[command(subcommand)] + action: DexCommands, + }, + /// Helper commands for ease of testing use + #[command()] + Helpers { + #[command(subcommand)] + action: HelperCommands, + }, + /// Print the aggregated CLI and relay configuration + #[command()] + ShowConfig, +} + +#[derive(Debug, Clone)] +struct CliAppContext { + agg_config: AggregatedConfig, + relay_processor: RelayProcessor, +} + +struct MakerSettlementCliContext { + grantor_collateral_token_utxo: OutPoint, + grantor_settlement_token_utxo: OutPoint, + fee_utxo: OutPoint, + asset_utxo: OutPoint, + fee_amount: u64, + price_at_current_block_height: u64, + oracle_signature: String, + grantor_amount_to_burn: u64, + maker_order_event_id: EventId, +} + +struct MakerSettlementTerminationCliContext { + fee_utxo: OutPoint, + settlement_asset_utxo: OutPoint, + grantor_settlement_token_utxo: OutPoint, + fee_amount: u64, + grantor_settlement_amount_to_burn: u64, + maker_order_event_id: EventId, +} + +struct MakerCollateralTerminationCliContext { + grantor_collateral_token_utxo: OutPoint, + fee_utxo: OutPoint, + collateral_token_utxo: OutPoint, + fee_amount: u64, + grantor_collateral_amount_to_burn: u64, + maker_order_event_id: EventId, +} + +struct MakerFundCliContext { + filler_token_utxo: OutPoint, + grantor_collateral_token_utxo: OutPoint, + grantor_settlement_token_utxo: OutPoint, + settlement_asset_utxo: OutPoint, + fee_utxo: OutPoint, + fee_amount: u64, + dcd_taproot_pubkey_gen: String, +} + +struct MakerInitCliContext { + first_lbtc_utxo: OutPoint, + second_lbtc_utxo: OutPoint, + third_lbtc_utxo: OutPoint, + init_order_args: InitOrderArgs, + fee_amount: u64, +} + +struct MergeTokens2CliContext { + token_utxo_1: OutPoint, + token_utxo_2: OutPoint, + fee_utxo: OutPoint, + fee_amount: u64, + maker_order_event_id: EventId, +} + +struct MergeTokens3CliContext { + token_utxo_1: OutPoint, + token_utxo_2: OutPoint, + token_utxo_3: OutPoint, + fee_utxo: OutPoint, + fee_amount: u64, + maker_order_event_id: EventId, +} + +struct MergeTokens4CliContext { + token_utxo_1: OutPoint, + token_utxo_2: OutPoint, + token_utxo_3: OutPoint, + token_utxo_4: OutPoint, + fee_utxo: OutPoint, + fee_amount: u64, + maker_order_event_id: EventId, +} + +impl Cli { + /// Initialize aggregated CLI configuration from CLI args, config file and env. + /// + /// # Errors + /// + /// Returns an error if building or validating the aggregated configuration + /// (including loading the config file or environment overrides) fails. + pub fn init_config(&self) -> crate::error::Result { + AggregatedConfig::new(self) + } + + /// Initialize the relay processor using the provided relays and optional keypair. + /// + /// # Errors + /// + /// Returns an error if creating or configuring the underlying Nostr relay + /// client fails, or if connecting to the specified relays fails. + pub async fn init_relays( + &self, + relays: &[RelayUrl], + keypair: Option, + ) -> crate::error::Result { + let relay_processor = RelayProcessor::try_from_config( + relays, + keypair, + ClientConfig { + timeout: Duration::from_secs(DEFAULT_CLIENT_TIMEOUT_SECS), + }, + ) + .await?; + Ok(relay_processor) + } + + /// Process the CLI command and execute the selected action. + /// + /// # Errors + /// + /// Returns an error if: + /// - Loading or validating the aggregated configuration fails. + /// - Initializing or communicating with Nostr relays fails. + /// - Any underlying contract handler (maker, taker, or helper) fails. + /// - Writing the resulting message to stdout fails. + #[instrument(skip(self))] + pub async fn process(self) -> crate::error::Result<()> { + let agg_config = self.init_config()?; + + let relay_processor = self + .init_relays(&agg_config.relays, agg_config.nostr_keypair.clone()) + .await?; + + let cli_app_context = CliAppContext { + agg_config, + relay_processor, + }; + let msg = { + match self.command { + Command::ShowConfig => { + format!("Config: {:#?}", cli_app_context.agg_config) + } + Command::Maker { action } => Self::process_maker_commands(&cli_app_context, action).await?, + Command::Taker { action } => Self::process_taker_commands(&cli_app_context, action).await?, + Command::Helpers { action } => Self::process_helper_commands(&cli_app_context, action).await?, + Command::Dex { action } => Self::process_dex_commands(&cli_app_context, action).await?, + } + }; + write_into_stdout(msg)?; + Ok(()) + } + + #[allow(clippy::too_many_lines)] + async fn process_maker_commands( + cli_app_context: &CliAppContext, + action: MakerCommands, + ) -> crate::error::Result { + Ok(match action { + MakerCommands::InitOrder { + first_lbtc_utxo, + second_lbtc_utxo, + third_lbtc_utxo, + init_order_args, + fee_amount, + common_options, + } => { + Self::_process_maker_init_order( + MakerInitCliContext { + first_lbtc_utxo, + second_lbtc_utxo, + third_lbtc_utxo, + init_order_args, + fee_amount, + }, + common_options, + ) + .await? + } + MakerCommands::Fund { + filler_token_utxo, + grantor_collateral_token_utxo, + grantor_settlement_token_utxo, + settlement_asset_utxo, + fee_utxo, + fee_amount, + dcd_taproot_pubkey_gen, + common_options, + } => { + Self::_process_maker_fund( + cli_app_context, + MakerFundCliContext { + filler_token_utxo, + grantor_collateral_token_utxo, + grantor_settlement_token_utxo, + settlement_asset_utxo, + fee_utxo, + fee_amount, + dcd_taproot_pubkey_gen, + }, + common_options, + ) + .await? + } + MakerCommands::TerminationCollateral { + grantor_collateral_token_utxo, + fee_utxo, + collateral_token_utxo, + fee_amount, + grantor_collateral_amount_to_burn, + maker_order_event_id, + common_options, + } => { + Self::_process_maker_termination_collateral( + cli_app_context, + MakerCollateralTerminationCliContext { + grantor_collateral_token_utxo, + fee_utxo, + collateral_token_utxo, + fee_amount, + grantor_collateral_amount_to_burn, + maker_order_event_id, + }, + common_options, + ) + .await? + } + MakerCommands::TerminationSettlement { + fee_utxo, + settlement_asset_utxo, + grantor_settlement_token_utxo, + fee_amount, + grantor_settlement_amount_to_burn, + maker_order_event_id, + common_options, + } => { + Self::_process_maker_termination_settlement( + cli_app_context, + MakerSettlementTerminationCliContext { + fee_utxo, + settlement_asset_utxo, + grantor_settlement_token_utxo, + fee_amount, + grantor_settlement_amount_to_burn, + maker_order_event_id, + }, + common_options, + ) + .await? + } + MakerCommands::Settlement { + grantor_collateral_token_utxo, + grantor_settlement_token_utxo, + asset_utxo, + fee_utxo, + fee_amount, + price_at_current_block_height, + oracle_signature, + grantor_amount_to_burn, + maker_order_event_id, + common_options, + } => { + Self::_process_maker_settlement( + cli_app_context, + MakerSettlementCliContext { + grantor_collateral_token_utxo, + grantor_settlement_token_utxo, + fee_utxo, + asset_utxo, + fee_amount, + price_at_current_block_height, + oracle_signature, + grantor_amount_to_burn, + maker_order_event_id, + }, + common_options, + ) + .await? + } + }) + } + + async fn _process_maker_init_order( + MakerInitCliContext { + first_lbtc_utxo, + second_lbtc_utxo, + third_lbtc_utxo, + init_order_args, + fee_amount, + }: MakerInitCliContext, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + use contract_handlers::maker_init::{Utxos, handle, process_args, save_args_to_cache}; + + let processed_args = process_args(account_index, init_order_args.into())?; + let (tx_res, args_to_save) = handle( + processed_args, + Utxos { + first: first_lbtc_utxo, + second: second_lbtc_utxo, + third: third_lbtc_utxo, + }, + fee_amount, + is_offline, + ) + .await?; + save_args_to_cache(&args_to_save)?; + Ok(format!("[Maker] Init order tx result: {tx_res:?}")) + } + + async fn _process_maker_fund( + CliAppContext { + agg_config, + relay_processor, + }: &CliAppContext, + MakerFundCliContext { + filler_token_utxo, + grantor_collateral_token_utxo, + grantor_settlement_token_utxo, + settlement_asset_utxo, + fee_utxo, + fee_amount, + dcd_taproot_pubkey_gen, + }: MakerFundCliContext, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + use contract_handlers::maker_funding::{Utxos, handle, process_args, save_args_to_cache}; + + agg_config.check_nostr_keypair_existence()?; + + let processed_args = process_args(account_index, dcd_taproot_pubkey_gen)?; + let event_to_publish = processed_args.extract_event(); + let (tx_id, args_to_save) = handle( + processed_args, + Utxos { + filler_token: filler_token_utxo, + grantor_collateral_token: grantor_collateral_token_utxo, + grantor_settlement_token: grantor_settlement_token_utxo, + settlement_asset: settlement_asset_utxo, + fee: fee_utxo, + }, + fee_amount, + is_offline, + ) + .await?; + let res = relay_processor.place_order(event_to_publish, tx_id).await?; + save_args_to_cache(&args_to_save)?; + Ok(format!("[Maker] Creating order, tx_id: {tx_id}, event_id: {res:#?}")) + } + + async fn _process_maker_termination_collateral( + CliAppContext { + agg_config, + relay_processor, + }: &CliAppContext, + MakerCollateralTerminationCliContext { + grantor_collateral_token_utxo, + fee_utxo, + collateral_token_utxo, + fee_amount, + grantor_collateral_amount_to_burn, + maker_order_event_id, + }: MakerCollateralTerminationCliContext, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + use contract_handlers::maker_termination_collateral::{Utxos, handle, save_args_to_cache}; + + agg_config.check_nostr_keypair_existence()?; + let processed_args = contract_handlers::maker_termination_collateral::process_args( + account_index, + grantor_collateral_amount_to_burn, + maker_order_event_id, + relay_processor, + ) + .await?; + let (tx_id, args_to_save) = handle( + processed_args, + Utxos { + grantor_collateral_token: grantor_collateral_token_utxo, + fee: fee_utxo, + collateral_token: collateral_token_utxo, + }, + fee_amount, + is_offline, + ) + .await?; + save_args_to_cache(&args_to_save)?; + let reply_event_id = relay_processor + .reply_order(maker_order_event_id, ReplyOption::MakerTerminationCollateral { tx_id }) + .await?; + Ok(format!( + "[Maker] Termination collateral tx result: {tx_id:?}, reply event id: {reply_event_id}" + )) + } + + async fn _process_maker_termination_settlement( + CliAppContext { + agg_config, + relay_processor, + }: &CliAppContext, + MakerSettlementTerminationCliContext { + fee_utxo, + settlement_asset_utxo, + grantor_settlement_token_utxo, + fee_amount, + grantor_settlement_amount_to_burn, + maker_order_event_id, + }: MakerSettlementTerminationCliContext, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + use contract_handlers::maker_termination_settlement::{Utxos, handle, save_args_to_cache}; + + agg_config.check_nostr_keypair_existence()?; + let processed_args = contract_handlers::maker_termination_settlement::process_args( + account_index, + grantor_settlement_amount_to_burn, + maker_order_event_id, + relay_processor, + ) + .await?; + let (tx_id, args_to_save) = handle( + processed_args, + Utxos { + fee: fee_utxo, + settlement_asset: settlement_asset_utxo, + grantor_settlement_token: grantor_settlement_token_utxo, + }, + fee_amount, + is_offline, + ) + .await?; + save_args_to_cache(&args_to_save)?; + let reply_event_id = relay_processor + .reply_order(maker_order_event_id, ReplyOption::MakerTerminationSettlement { tx_id }) + .await?; + Ok(format!( + "[Maker] Termination settlement tx result: {tx_id:?}, reply event id: {reply_event_id}" + )) + } + + #[allow(clippy::too_many_lines)] + async fn _process_maker_settlement( + CliAppContext { + agg_config, + relay_processor, + }: &CliAppContext, + MakerSettlementCliContext { + grantor_collateral_token_utxo, + grantor_settlement_token_utxo, + fee_utxo, + asset_utxo, + fee_amount, + price_at_current_block_height, + oracle_signature, + grantor_amount_to_burn, + maker_order_event_id, + }: MakerSettlementCliContext, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + use contract_handlers::maker_settlement::{Utxos, handle, process_args, save_args_to_cache}; + + agg_config.check_nostr_keypair_existence()?; + let processed_args = process_args( + account_index, + price_at_current_block_height, + oracle_signature, + grantor_amount_to_burn, + maker_order_event_id, + relay_processor, + ) + .await?; + let (tx_id, args_to_save) = handle( + processed_args, + Utxos { + grantor_collateral_token: grantor_collateral_token_utxo, + grantor_settlement_token: grantor_settlement_token_utxo, + fee: fee_utxo, + asset: asset_utxo, + }, + fee_amount, + is_offline, + ) + .await?; + save_args_to_cache(&args_to_save)?; + let reply_event_id = relay_processor + .reply_order(maker_order_event_id, ReplyOption::MakerSettlement { tx_id }) + .await?; + Ok(format!( + "[Maker] Final settlement tx result: {tx_id:?}, reply event id: {reply_event_id}" + )) + } + + #[allow(clippy::too_many_lines)] + async fn process_taker_commands( + CliAppContext { + agg_config, + relay_processor, + }: &CliAppContext, + action: TakerCommands, + ) -> crate::error::Result { + Ok(match action { + TakerCommands::FundOrder { + filler_token_utxo, + collateral_token_utxo, + fee_amount, + collateral_amount_to_deposit, + common_options, + maker_order_event_id, + } => { + use contract_handlers::taker_funding::{Utxos, handle, process_args, save_args_to_cache}; + + agg_config.check_nostr_keypair_existence()?; + let processed_args = process_args( + common_options.account_index, + collateral_amount_to_deposit, + maker_order_event_id, + relay_processor, + ) + .await?; + let (tx_id, args_to_save) = handle( + processed_args, + Utxos { + filler_token_utxo, + collateral_token_utxo, + }, + fee_amount, + common_options.is_offline, + ) + .await?; + let reply_event_id = relay_processor + .reply_order(maker_order_event_id, ReplyOption::TakerFund { tx_id }) + .await?; + save_args_to_cache(&args_to_save)?; + format!("[Taker] Tx fund sending result: {tx_id:?}, reply event id: {reply_event_id}") + } + TakerCommands::TerminationEarly { + filler_token_utxo, + collateral_token_utxo, + fee_utxo, + fee_amount, + filler_token_amount_to_return, + common_options, + maker_order_event_id, + } => { + use contract_handlers::taker_early_termination::{Utxos, handle, process_args, save_args_to_cache}; + + agg_config.check_nostr_keypair_existence()?; + let processed_args = process_args( + common_options.account_index, + filler_token_amount_to_return, + maker_order_event_id, + relay_processor, + ) + .await?; + let (tx_id, args_to_save) = handle( + processed_args, + Utxos { + filler_token: filler_token_utxo, + collateral_token: collateral_token_utxo, + fee: fee_utxo, + }, + fee_amount, + common_options.is_offline, + ) + .await?; + let reply_event_id = relay_processor + .reply_order(maker_order_event_id, ReplyOption::TakerTerminationEarly { tx_id }) + .await?; + save_args_to_cache(&args_to_save)?; + format!("[Taker] Early termination tx result: {tx_id:?}, reply event id: {reply_event_id}") + } + TakerCommands::Settlement { + filler_token_utxo, + asset_utxo, + fee_utxo, + fee_amount, + price_at_current_block_height, + filler_amount_to_burn, + oracle_signature, + common_options, + maker_order_event_id, + } => { + use contract_handlers::taker_settlement::{Utxos, handle, process_args, save_args_to_cache}; + + agg_config.check_nostr_keypair_existence()?; + let processed_args = process_args( + common_options.account_index, + price_at_current_block_height, + filler_amount_to_burn, + oracle_signature, + maker_order_event_id, + relay_processor, + ) + .await?; + let (tx_id, args_to_save) = handle( + processed_args, + Utxos { + filler_token: filler_token_utxo, + asset: asset_utxo, + fee: fee_utxo, + }, + fee_amount, + common_options.is_offline, + ) + .await?; + save_args_to_cache(&args_to_save)?; + let reply_event_id = relay_processor + .reply_order(maker_order_event_id, ReplyOption::TakerSettlement { tx_id }) + .await?; + format!("[Taker] Final settlement tx result: {tx_id:?}, reply event id: {reply_event_id}") + } + }) + } + + #[allow(clippy::too_many_lines)] + async fn process_helper_commands( + cli_app_context: &CliAppContext, + action: HelperCommands, + ) -> crate::error::Result { + Ok(match action { + HelperCommands::Faucet { + fee_utxo_outpoint, + asset_name, + issue_amount, + fee_amount, + common_options, + } => { + Self::_process_helper_faucet(fee_utxo_outpoint, asset_name, issue_amount, fee_amount, common_options) + .await? + } + HelperCommands::MintTokens { + reissue_asset_outpoint, + fee_utxo_outpoint, + asset_name, + reissue_amount, + fee_amount, + common_options, + } => { + Self::_process_helper_mint_tokens( + reissue_asset_outpoint, + fee_utxo_outpoint, + asset_name, + reissue_amount, + fee_amount, + common_options, + ) + .await? + } + HelperCommands::SplitNativeThree { + split_amount, + fee_utxo, + fee_amount, + common_options, + } => Self::_process_helper_split_native_three(split_amount, fee_utxo, fee_amount, common_options).await?, + HelperCommands::Address { account_index: index } => Self::_process_helper_address(index)?, + HelperCommands::OracleSignature { + price_at_current_block_height, + settlement_height, + oracle_account_index, + } => Self::_process_helper_oracle_signature( + price_at_current_block_height, + settlement_height, + oracle_account_index, + )?, + HelperCommands::MergeTokens2 { + token_utxo_1, + token_utxo_2, + fee_utxo, + fee_amount, + maker_order_event_id, + common_options, + } => { + Self::_process_helper_merge_tokens2( + cli_app_context, + MergeTokens2CliContext { + token_utxo_1, + token_utxo_2, + fee_utxo, + fee_amount, + maker_order_event_id, + }, + common_options, + ) + .await? + } + HelperCommands::MergeTokens3 { + token_utxo_1, + token_utxo_2, + token_utxo_3, + fee_utxo, + fee_amount, + maker_order_event_id, + common_options, + } => { + Self::_process_helper_merge_tokens3( + cli_app_context, + MergeTokens3CliContext { + token_utxo_1, + token_utxo_2, + token_utxo_3, + fee_utxo, + fee_amount, + maker_order_event_id, + }, + common_options, + ) + .await? + } + HelperCommands::MergeTokens4 { + token_utxo_1, + token_utxo_2, + token_utxo_3, + token_utxo_4, + fee_utxo, + fee_amount, + maker_order_event_id, + common_options, + } => { + Self::_process_helper_merge_tokens4( + cli_app_context, + MergeTokens4CliContext { + token_utxo_1, + token_utxo_2, + token_utxo_3, + token_utxo_4, + fee_utxo, + fee_amount, + maker_order_event_id, + }, + common_options, + ) + .await? + } + }) + } + + async fn _process_helper_faucet( + fee_utxo_outpoint: OutPoint, + asset_name: String, + issue_amount: u64, + fee_amount: u64, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + let tx_id = contract_handlers::faucet::create_asset( + account_index, + asset_name, + fee_utxo_outpoint, + fee_amount, + issue_amount, + is_offline, + ) + .await?; + Ok(format!("Finish asset creation, tx_id: {tx_id}")) + } + + async fn _process_helper_mint_tokens( + reissue_asset_outpoint: OutPoint, + fee_utxo_outpoint: OutPoint, + asset_name: String, + reissue_amount: u64, + fee_amount: u64, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + let tx_id = contract_handlers::faucet::mint_asset( + account_index, + asset_name, + reissue_asset_outpoint, + fee_utxo_outpoint, + reissue_amount, + fee_amount, + is_offline, + ) + .await?; + Ok(format!("Finish asset minting, tx_id: {tx_id} ")) + } + + async fn _process_helper_split_native_three( + split_amount: u64, + fee_utxo: OutPoint, + fee_amount: u64, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + let tx_res = + contract_handlers::split_utxo::handle(account_index, split_amount, fee_utxo, fee_amount, is_offline) + .await?; + Ok(format!("Split utxo result tx_id: {tx_res:?}")) + } + + fn _process_helper_address(index: u32) -> crate::error::Result { + let (x_only_pubkey, addr) = contract_handlers::address::handle(index)?; + Ok(format!("X Only Public Key: '{x_only_pubkey}', P2PK Address: '{addr}'")) + } + + fn _process_helper_oracle_signature( + price_at_current_block_height: u64, + settlement_height: u32, + oracle_account_index: u32, + ) -> crate::error::Result { + let (pubkey, msg, signature) = contract_handlers::oracle_signature::handle( + oracle_account_index, + price_at_current_block_height, + settlement_height, + )?; + Ok(format!( + "Oracle signature for msg: '{}', signature: '{}', pubkey used: '{}'", + msg.to_hex(), + hex::encode(signature.serialize()), + pubkey.x_only_public_key().0.to_hex() + )) + } + + async fn _process_helper_merge_tokens2( + CliAppContext { + agg_config, + relay_processor, + }: &CliAppContext, + MergeTokens2CliContext { + token_utxo_1, + token_utxo_2, + fee_utxo, + fee_amount, + maker_order_event_id, + }: MergeTokens2CliContext, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + use contract_handlers::merge_tokens::{ + merge2::{Utxos2, handle}, + process_args, save_args_to_cache, + }; + + agg_config.check_nostr_keypair_existence()?; + let processed_args = process_args(account_index, maker_order_event_id, relay_processor).await?; + let (tx_id, args_to_save) = handle( + processed_args, + Utxos2 { + utxo_1: token_utxo_1, + utxo_2: token_utxo_2, + fee: fee_utxo, + }, + fee_amount, + is_offline, + ) + .await?; + save_args_to_cache(&args_to_save)?; + let reply_event_id = relay_processor + .reply_order( + maker_order_event_id, + ReplyOption::Merge2 { + tx_id, + token_utxo_1, + token_utxo_2, + }, + ) + .await?; + Ok(format!( + "[Taker] Final merge 2 tx result: {tx_id:?}, reply event id: {reply_event_id}" + )) + } + + async fn _process_helper_merge_tokens3( + CliAppContext { + agg_config, + relay_processor, + }: &CliAppContext, + MergeTokens3CliContext { + token_utxo_1, + token_utxo_2, + token_utxo_3, + fee_utxo, + fee_amount, + maker_order_event_id, + }: MergeTokens3CliContext, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + use contract_handlers::merge_tokens::{ + merge3::{Utxos3, handle}, + process_args, save_args_to_cache, + }; + + agg_config.check_nostr_keypair_existence()?; + let processed_args = process_args(account_index, maker_order_event_id, relay_processor).await?; + let (tx_id, args_to_save) = handle( + processed_args, + Utxos3 { + utxo_1: token_utxo_1, + utxo_2: token_utxo_2, + utxo_3: token_utxo_3, + fee: fee_utxo, + }, + fee_amount, + is_offline, + ) + .await?; + save_args_to_cache(&args_to_save)?; + let reply_event_id = relay_processor + .reply_order( + maker_order_event_id, + ReplyOption::Merge3 { + tx_id, + token_utxo_1, + token_utxo_2, + token_utxo_3, + }, + ) + .await?; + Ok(format!( + "[Taker] Final merge 3 tx result: {tx_id:?}, reply event id: {reply_event_id}" + )) + } + + async fn _process_helper_merge_tokens4( + CliAppContext { + agg_config, + relay_processor, + }: &CliAppContext, + MergeTokens4CliContext { + token_utxo_1, + token_utxo_2, + token_utxo_3, + token_utxo_4, + fee_utxo, + fee_amount, + maker_order_event_id, + }: MergeTokens4CliContext, + CommonOrderOptions { + account_index, + is_offline, + }: CommonOrderOptions, + ) -> crate::error::Result { + use contract_handlers::merge_tokens::{ + merge4::{Utxos4, handle}, + process_args, save_args_to_cache, + }; + + agg_config.check_nostr_keypair_existence()?; + let processed_args = process_args(account_index, maker_order_event_id, relay_processor).await?; + let (tx_id, args_to_save) = handle( + processed_args, + Utxos4 { + utxo_1: token_utxo_1, + utxo_2: token_utxo_2, + utxo_3: token_utxo_3, + utxo_4: token_utxo_4, + fee: fee_utxo, + }, + fee_amount, + is_offline, + ) + .await?; + save_args_to_cache(&args_to_save)?; + let reply_event_id = relay_processor + .reply_order( + maker_order_event_id, + ReplyOption::Merge4 { + tx_id, + token_utxo_1, + token_utxo_2, + token_utxo_3, + token_utxo_4, + }, + ) + .await?; + Ok(format!( + "[Taker] Final merge 4 tx result: {tx_id:?}, reply event id: {reply_event_id}" + )) + } + + async fn process_dex_commands( + CliAppContext { relay_processor, .. }: &CliAppContext, + action: DexCommands, + ) -> crate::error::Result { + Ok(match action { + DexCommands::GetOrderReplies { event_id } => { + let res = relay_processor.get_order_replies(event_id).await?; + format!("Order '{event_id}' replies: {res:#?}") + } + DexCommands::ListOrders { + authors, + time_to_filter, + limit, + } => { + let (since, until) = if let Some(time_filter) = time_to_filter { + (time_filter.compute_since(), time_filter.compute_until()) + } else { + (None, None) + }; + + let filter = ListOrdersEventFilter { + authors, + since: since.map(Timestamp::from), + until: until.map(Timestamp::from), + limit, + }; + + let res = relay_processor.list_orders(filter).await?; + let body = format_items(&res, std::string::ToString::to_string); + format!("List of available orders:\n{body}") + } + DexCommands::GetEventsById { event_id } => { + let res = relay_processor.get_event_by_id(event_id).await?; + format!("List of available events: {res:#?}") + } + DexCommands::GetOrderById { event_id } => { + let res = relay_processor.get_order_by_id(event_id).await?; + let body = format_items(&[res], std::string::ToString::to_string); + format!("Order {event_id}: {body}") + } + DexCommands::ImportParams { event_id } => { + let res = relay_processor.get_order_by_id(event_id).await?; + crate::common::store::utils::save_dcd_args(&res.dcd_taproot_pubkey_gen, &res.dcd_arguments)?; + format!("Order {event_id}: {res}") + } + }) + } +} + +fn format_items(items: &[T], map: F) -> String +where + F: Fn(&T) -> String, +{ + items.iter().map(map).collect::>().join("\n") +} diff --git a/crates/dex-cli/src/cli/taker.rs b/crates/dex-cli/src/cli/taker.rs new file mode 100644 index 0000000..8ec53a8 --- /dev/null +++ b/crates/dex-cli/src/cli/taker.rs @@ -0,0 +1,87 @@ +use crate::cli::processor::CommonOrderOptions; +use clap::Subcommand; +use nostr::EventId; +use simplicity::elements::OutPoint; + +#[derive(Debug, Subcommand)] +pub enum TakerCommands { + #[command( + about = "Fund an existing DCD order as Taker and lock collateral into the contract [authentication required]", + name = "fund" + )] + FundOrder { + /// UTXO containing filler tokens provided by the Taker to fund the contract + #[arg(long = "filler-utxo")] + filler_token_utxo: OutPoint, + /// UTXO containing collateral asset that the Taker locks into the DCD contract + #[arg(long = "collateral-utxo")] + collateral_token_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the Taker funding transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// Amount of collateral (in satoshis) that the Taker will lock into the DCD contract + #[arg(long = "collateral-amount-deposit")] + collateral_amount_to_deposit: u64, + /// `EventId` of the Maker\'s original order event on Nostr + #[arg(short = 'i', long)] + maker_order_event_id: EventId, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command( + about = "Exit the DCD contract early as Taker by returning filler tokens in exchange for your collateral" + )] + TerminationEarly { + /// UTXO containing filler tokens that the Taker returns to exit the contract early + #[arg(long = "filler-utxo")] + filler_token_utxo: OutPoint, + /// UTXO containing the collateral asset that the Taker will withdraw back + #[arg(long = "collateral-utxo")] + collateral_token_utxo: OutPoint, + /// UTXO used to pay miner fees for the early-termination transaction + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the early-termination transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// Amount of filler tokens (in satoshis) that the Taker returns to exit early + #[arg(long = "filler-to-return")] + filler_token_amount_to_return: u64, + /// `EventId` of the Maker\'s original order event on Nostr + #[arg(short = 'i', long)] + maker_order_event_id: EventId, + #[command(flatten)] + common_options: CommonOrderOptions, + }, + #[command( + about = "Settle the Taker side of the DCD at maturity using an oracle price to choose collateral or settlement asset" + )] + Settlement { + /// UTXO containing filler tokens that the Taker burns during settlement + #[arg(long = "filler-utxo")] + filler_token_utxo: OutPoint, + /// UTXO providing the asset (collateral or settlement) that the Taker receives at maturity + #[arg(long = "asset-utxo")] + asset_utxo: OutPoint, + /// UTXO used to pay miner fees for the final Taker settlement transaction + #[arg(long = "fee-utxo")] + fee_utxo: OutPoint, + /// Miner fee in satoshis (LBTC) for the final Taker settlement transaction + #[arg(long = "fee-amount", default_value_t = 1500)] + fee_amount: u64, + /// Amount of filler tokens (in satoshis) that the Taker burns during settlement + #[arg(long = "filler-to-burn")] + filler_amount_to_burn: u64, + /// Oracle price at current block height used for settlement decision + #[arg(long = "price-now")] + price_at_current_block_height: u64, + /// Schnorr/ecdsa signature produced by the oracle over the published price + #[arg(long = "oracle-sign")] + oracle_signature: String, + /// `EventId` of the Maker\'s original order event on Nostr + #[arg(short = 'i', long)] + maker_order_event_id: EventId, + #[command(flatten)] + common_options: CommonOrderOptions, + }, +} diff --git a/crates/dex-cli/src/common/config.rs b/crates/dex-cli/src/common/config.rs new file mode 100644 index 0000000..6b87e91 --- /dev/null +++ b/crates/dex-cli/src/common/config.rs @@ -0,0 +1,128 @@ +use crate::cli::{Cli, DEFAULT_CONFIG_PATH}; +use crate::error::CliError::ConfigExtended; + +use std::str::FromStr; + +use config::{Config, File, FileFormat, ValueKind}; + +use nostr::{Keys, RelayUrl}; + +use serde::{Deserialize, Deserializer}; + +use crate::error::CliError; +use tracing::instrument; + +#[derive(Debug, Clone)] +pub struct AggregatedConfig { + pub nostr_keypair: Option, + pub relays: Vec, +} + +#[derive(Debug, Clone)] +pub struct KeysWrapper(pub Keys); + +impl<'de> Deserialize<'de> for KeysWrapper { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let keys = Keys::from_str(&s).map_err(serde::de::Error::custom)?; + Ok(KeysWrapper(keys)) + } +} + +impl From for ValueKind { + fn from(val: KeysWrapper) -> Self { + ValueKind::String(val.0.secret_key().to_secret_hex()) + } +} + +impl AggregatedConfig { + /// Build aggregated configuration from CLI arguments and optional config file. + /// + /// # Errors + /// + /// Returns: + /// - `CliError::Config` if the underlying `config` builder or deserialization fails. + /// - `CliError::ConfigExtended` if the aggregated configuration cannot be + /// constructed (e.g., missing or empty `relays` list). + #[instrument(level = "debug", skip(cli))] + pub fn new(cli: &Cli) -> crate::error::Result { + #[derive(Deserialize, Debug)] + pub struct AggregatedConfigInner { + pub nostr_keypair: Option, + pub relays: Option>, + } + + let Cli { + nostr_key, + relays_list, + nostr_config_path, + .. + } = cli; + + let mut config_builder = Config::builder().add_source( + File::from(nostr_config_path.clone()) + .format(FileFormat::Toml) + .required(DEFAULT_CONFIG_PATH != nostr_config_path.to_string_lossy().as_ref()), + ); + + if let Some(nostr_key) = nostr_key { + tracing::debug!("Adding keypair value from CLI"); + config_builder = + config_builder.set_override_option("nostr_keypair", Some(KeysWrapper(nostr_key.clone())))?; + } + + if let Some(relays) = relays_list { + tracing::debug!("Adding relays values from CLI, relays: '{:?}'", relays); + config_builder = config_builder.set_override_option( + "relays", + Some( + relays + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + ), + )?; + } + + // TODO(Alex): add Liquid private key + + let config = match config_builder.build()?.try_deserialize::() { + Ok(conf) => Ok(conf), + Err(e) => Err(ConfigExtended(format!( + "Got error in gathering AggregatedConfigInner, error: {e:?}" + ))), + }?; + + let Some(relays) = config.relays else { + return Err(ConfigExtended("No relays found in configuration..".to_string())); + }; + + if relays.is_empty() { + return Err(ConfigExtended("Relays configuration is empty..".to_string())); + } + + let aggregated_config = AggregatedConfig { + nostr_keypair: config.nostr_keypair.map(|x| x.0), + relays, + }; + + tracing::debug!("Config gathered: '{:?}'", aggregated_config); + + Ok(aggregated_config) + } + + /// Ensure that a Nostr keypair is present in the aggregated configuration. + /// + /// # Errors + /// + /// Returns `CliError::NoNostrKeypairListed` if `nostr_keypair` is `None`. + pub fn check_nostr_keypair_existence(&self) -> crate::error::Result<()> { + if self.nostr_keypair.is_none() { + return Err(CliError::NoNostrKeypairListed); + } + Ok(()) + } +} diff --git a/crates/dex-cli/src/common/keys.rs b/crates/dex-cli/src/common/keys.rs new file mode 100644 index 0000000..6a0eeb4 --- /dev/null +++ b/crates/dex-cli/src/common/keys.rs @@ -0,0 +1,98 @@ +use simplicityhl::elements::secp256k1_zkp as secp256k1; + +/// # Panics +/// +/// Will panic if `SEED_HEX` is in incorrect encoding that differs from hex +#[must_use] +pub fn derive_secret_key_from_index(index: u32, seed_hex: impl AsRef<[u8]>) -> secp256k1::SecretKey { + // TODO (Oleks): fix possible panic, propagate error & move this parameter into config + let seed_vec = hex::decode(seed_hex).expect("SEED_HEX must be hex"); + assert_eq!(seed_vec.len(), 32, "SEED_HEX must be 32 bytes hex"); + + let mut seed_bytes = [0u8; 32]; + seed_bytes.copy_from_slice(&seed_vec); + + let mut seed = seed_bytes; + for (i, b) in index.to_be_bytes().iter().enumerate() { + seed[24 + i] ^= *b; + } + secp256k1::SecretKey::from_slice(&seed).unwrap() +} + +pub fn derive_keypair_from_index(index: u32, seed_hex: impl AsRef<[u8]>) -> secp256k1::Keypair { + elements::bitcoin::secp256k1::Keypair::from_secret_key( + elements::bitcoin::secp256k1::SECP256K1, + &derive_secret_key_from_index(index, seed_hex), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use elements::hex::ToHex; + use proptest::prelude::*; + use simplicityhl::elements; + use simplicityhl::elements::AddressParams; + use simplicityhl_core::get_p2pk_address; + + fn check_seed_hex_gen( + index: u32, + x_only_pubkey: &str, + p2pk_addr: &str, + seed_hex: impl AsRef<[u8]>, + ) -> anyhow::Result<()> { + let keypair = derive_keypair_from_index(index, &seed_hex); + + let public_key = keypair.x_only_public_key().0; + let address = get_p2pk_address(&public_key, &AddressParams::LIQUID_TESTNET)?; + + assert_eq!(public_key.to_string(), x_only_pubkey); + assert_eq!(address.to_string(), p2pk_addr); + Ok(()) + } + + #[test] + fn derive_keypair_from_index_is_deterministic_for_seed() -> anyhow::Result<()> { + const SEED_HEX: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + let expected_secrets = [ + ( + 0u32, + "4646ae5047316b4230d0086c8acec687f00b1cd9d1dc634f6cb358ac0a9a8fff", + "tex1pyzkfajdprt6gl6288z54c6m4lrg3vp32cajmqrh5kfaegydyrv0qtcg6lm", + ), + ( + 1u32, + "16e47b8867bfbeaae66c0345577751c551903eb90ba479e91f783c507c088732", + "tex1prmytj5v08w6jwjtm4exmuxv0nn8favzyqu3aptzrgvl44nfatqmsykjhk3", + ), + ( + 2u32, + "d0d0fce6bc500821c33212666ecfbd9d41a1414d584af4102e7441277d25d872", + "tex1phctnz400pn7r3rhh8nyc2xmsg2e9h2n299a8ld4pup0v5def9cdsjz3put", + ), + ]; + let check_address_with_index = |i| -> anyhow::Result<()> { + let (index, x_only_pubkey, p2pk_addr) = expected_secrets[i]; + check_seed_hex_gen(index, x_only_pubkey, p2pk_addr, SEED_HEX)?; + Ok(()) + }; + + check_address_with_index(0)?; + check_address_with_index(1)?; + check_address_with_index(2)?; + Ok(()) + } + + proptest! { + #[test] + fn prop_keypair_determinism(index in 0u32..u32::MAX, seed in any::<[u8; 32]>()) { + let seed_hex = seed.to_hex(); + + let kp1 = derive_keypair_from_index(index, &seed_hex); + let kp2 = derive_keypair_from_index(index, &seed_hex); + + prop_assert_eq!(kp1.secret_bytes(), kp2.secret_bytes()); + } + } +} diff --git a/crates/dex-cli/src/common/mod.rs b/crates/dex-cli/src/common/mod.rs new file mode 100644 index 0000000..c7fdbe5 --- /dev/null +++ b/crates/dex-cli/src/common/mod.rs @@ -0,0 +1,9 @@ +pub mod config; +pub mod keys; +pub mod settings; +pub(crate) mod store; +mod types; +mod utils; + +pub use types::*; +pub use utils::*; diff --git a/crates/dex-cli/src/common/settings.rs b/crates/dex-cli/src/common/settings.rs new file mode 100644 index 0000000..9481494 --- /dev/null +++ b/crates/dex-cli/src/common/settings.rs @@ -0,0 +1,45 @@ +use config::{Case, Config}; +use tracing::instrument; + +pub struct Seed(pub SeedInner); +pub type SeedInner = [u8; 32]; +pub struct SeedHex { + pub seed_hex: String, +} + +impl SeedHex { + pub const ENV_NAME: &'static str = "SEED_HEX"; +} + +#[derive(Clone, Debug)] +pub struct Settings { + pub seed_hex: String, +} + +impl Settings { + /// Load CLI settings from environment variables. + /// + /// # Errors + /// + /// Returns: + /// - `CliError::Config` if building the configuration from the environment fails. + /// - `CliError::EnvNotSet` if the [`SeedHex::ENV_NAME`] environment variable is not set + /// or cannot be read as a UTF-8 string. + #[instrument(level = "debug", ret)] + pub fn load() -> crate::error::Result { + let cfg = Config::builder() + .add_source( + config::Environment::default() + .separator("__") + .convert_case(Case::ScreamingSnake), + ) + .build() + .map_err(crate::error::CliError::Config)?; + + let seed_hex = cfg + .get_string(SeedHex::ENV_NAME) + .map_err(|_| crate::error::CliError::EnvNotSet(SeedHex::ENV_NAME.to_string()))?; + + Ok(Self { seed_hex }) + } +} diff --git a/crates/dex-cli/src/common/store.rs b/crates/dex-cli/src/common/store.rs new file mode 100644 index 0000000..6156f60 --- /dev/null +++ b/crates/dex-cli/src/common/store.rs @@ -0,0 +1,393 @@ +use hex::FromHexError; +use simplicityhl::simplicity::bitcoin::XOnlyPublicKey; +use simplicityhl::simplicity::elements::{Address, AddressParams}; +use simplicityhl_core::{Encodable, TaprootPubkeyGen}; +use sled::IVec; +use thiserror::Error; + +#[derive(Clone, Debug)] +pub struct Store { + store: sled::Db, +} + +#[derive(Error, Debug)] +pub enum SledError { + #[error(transparent)] + Sled(#[from] sled::Error), + #[error("Arguments not found")] + ArgumentNotFound, + #[error("Encodable error, msg: {0}")] + Encode(String), + #[error("Hex parsing error, msg: {0}")] + Hex(#[from] FromHexError), + #[error("Tap root gen error, msg: {0}")] + TapRootGen(String), +} + +pub type Result = std::result::Result; + +impl Store { + pub fn load() -> Result { + Ok(Self { + store: sled::open(".cache/store")?, + }) + } + + pub fn is_exist(&self, asset_name: &str) -> Result { + Ok(self.store.get(asset_name)?.is_some()) + } + + pub fn insert_value(&self, key: K, value: V) -> Result> + where + K: AsRef<[u8]>, + V: Into, + { + Ok(self.store.insert(key, value)?) + } + + pub fn get_value>(&self, key: K) -> Result> { + Ok(self.store.get(key)?) + } + + #[allow(unused)] + pub fn import_arguments( + &self, + taproot_pubkey_gen: &str, + encoded_data: &str, + params: &'static AddressParams, + get_address: &impl Fn(&XOnlyPublicKey, &A, &'static AddressParams) -> anyhow::Result
, + ) -> Result<()> + where + A: Encodable + simplicityhl_core::encoding::Decode<()>, + { + let decoded_data = hex::decode(encoded_data)?; + + let arguments = Encodable::decode(&decoded_data).map_err(|e| SledError::Encode(e.to_string()))?; + let _ = TaprootPubkeyGen::build_from_str(taproot_pubkey_gen, &arguments, params, get_address) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + self.store.insert(taproot_pubkey_gen, decoded_data)?; + + Ok(()) + } + + #[allow(unused)] + pub fn export_arguments(&self, taproot_pubkey_gen: &str) -> Result { + if let Some(value) = self.store.get(taproot_pubkey_gen)? { + return Ok(hex::encode(value)); + } + + Err(SledError::ArgumentNotFound) + } + + pub fn get_arguments(&self, key: impl AsRef<[u8]>) -> Result + where + A: Encodable + simplicityhl_core::encoding::Decode<()>, + { + if let Some(value) = self.store.get(key)? { + return Encodable::decode(&value).map_err(|err| SledError::Encode(err.to_string())); + } + Err(SledError::ArgumentNotFound) + } + + pub fn get_arguments_raw(&self, key: impl AsRef<[u8]>) -> Result { + self.store.get(key)?.ok_or_else(|| SledError::ArgumentNotFound) + } +} + +pub mod utils { + use crate::common::store::Store; + use contracts::DCDArguments; + use nostr::EventId; + use simplicityhl_core::{AssetEntropyHex, Encodable}; + + const FILLER_TOKEN_ENTROPY_STORE_NAME: &str = "filler_token_entropy"; + const GRANTOR_COLLATERAL_TOKEN_ENTROPY_STORE_NAME: &str = "grantor_collateral_token_entropy"; + const GRANTOR_SETTLEMENT_TOKEN_ENTROPY_STORE_NAME: &str = "grantor_settlement_token_entropy"; + + #[derive(Debug, bincode::Encode, bincode::Decode)] + pub struct OrderParams { + pub taproot_pubkey_gen: String, + pub dcd_args: DCDArguments, + } + + pub fn save_dcd_args(taproot_pubkey: &impl ToString, dcd_args: &DCDArguments) -> crate::error::Result<()> { + let store = Store::load()?; + store.insert_value(taproot_pubkey.to_string(), dcd_args.encode().unwrap())?; + Ok(()) + } + + pub fn get_dcd_args(taproot_pubkey: &impl ToString) -> crate::error::Result { + let store = Store::load()?; + let dcd_args: DCDArguments = store.get_arguments(taproot_pubkey.to_string())?; + Ok(dcd_args) + } + + fn get_filler_entropy_key(taproot_pubkey: &impl ToString) -> String { + format!("{}-{FILLER_TOKEN_ENTROPY_STORE_NAME}", taproot_pubkey.to_string()) + } + + pub fn save_filler_token_entropy( + taproot_pubkey: &impl ToString, + asset_entropy: &AssetEntropyHex, + ) -> crate::error::Result<()> { + let store = Store::load()?; + store.insert_value(get_filler_entropy_key(taproot_pubkey), asset_entropy.as_bytes())?; + Ok(()) + } + + pub fn get_filler_token_entropy(taproot_pubkey: &impl ToString) -> crate::error::Result { + let store = Store::load()?; + let bytes = store.get_arguments_raw(get_filler_entropy_key(taproot_pubkey))?; + let x = String::from_utf8(bytes.to_vec()).map_err(|err| { + crate::error::CliError::Cache(format!( + "Failed to obtain cached value for 'filler_token_entropy', err: {err}" + )) + })?; + Ok(x) + } + + fn get_grantor_collateral_token_entropy_key(taproot_pubkey: &impl ToString) -> String { + format!( + "{}-{GRANTOR_COLLATERAL_TOKEN_ENTROPY_STORE_NAME}", + taproot_pubkey.to_string() + ) + } + + pub fn save_grantor_collateral_token_entropy( + taproot_pubkey: &impl ToString, + asset_entropy: &AssetEntropyHex, + ) -> crate::error::Result<()> { + let store = Store::load()?; + store.insert_value( + get_grantor_collateral_token_entropy_key(taproot_pubkey), + asset_entropy.as_bytes(), + )?; + Ok(()) + } + + pub fn get_grantor_collateral_token_entropy( + taproot_pubkey: &impl ToString, + ) -> crate::error::Result { + let store = Store::load()?; + let bytes = store.get_arguments_raw(get_grantor_collateral_token_entropy_key(taproot_pubkey))?; + let x = String::from_utf8(bytes.to_vec()).map_err(|err| { + crate::error::CliError::Cache(format!( + "Failed to obtain cached value for 'filler_token_entropy', err: {err}" + )) + })?; + Ok(x) + } + + fn get_grantor_settlement_token_entropy_key(taproot_pubkey: &impl ToString) -> String { + format!( + "{}-{GRANTOR_SETTLEMENT_TOKEN_ENTROPY_STORE_NAME}", + taproot_pubkey.to_string() + ) + } + + pub fn save_grantor_settlement_token_entropy( + taproot_pubkey: &impl ToString, + asset_entropy: &AssetEntropyHex, + ) -> crate::error::Result<()> { + let store = Store::load()?; + store.insert_value( + get_grantor_settlement_token_entropy_key(taproot_pubkey), + asset_entropy.as_bytes(), + )?; + Ok(()) + } + + pub fn get_grantor_settlement_token_entropy( + taproot_pubkey: &impl ToString, + ) -> crate::error::Result { + let store = Store::load()?; + let bytes = store.get_arguments_raw(get_grantor_settlement_token_entropy_key(taproot_pubkey))?; + let x = String::from_utf8(bytes.to_vec()).map_err(|err| { + crate::error::CliError::Cache(format!( + "Failed to obtain cached value for 'filler_token_entropy', err: {err}" + )) + })?; + Ok(x) + } + + pub fn save_order_params_by_event_id( + event_id: EventId, + taproot_pubkey: &impl ToString, + asset_entropy: DCDArguments, + ) -> crate::error::Result<()> { + let bytes = bincode::encode_to_vec( + OrderParams { + taproot_pubkey_gen: taproot_pubkey.to_string(), + dcd_args: asset_entropy, + }, + bincode::config::standard(), + ) + .unwrap(); + let store = Store::load()?; + store.insert_value(event_id, bytes)?; + Ok(()) + } + + pub fn get_order_params_by_event_id(event_id: EventId) -> crate::error::Result { + let store = Store::load()?; + let bytes = store.get_arguments_raw(event_id)?.to_vec(); + let decoded: OrderParams = bincode::decode_from_slice(&bytes, bincode::config::standard()) + .map_err(|err| { + crate::error::CliError::Cache(format!("Failed to obtain order params by event id, err: {err}")) + })? + .0; + Ok(decoded) + } +} + +#[cfg(test)] +mod tests { + use contracts::get_options_program; + use simplicityhl::simplicity::elements; + use simplicityhl_core::{Encodable, TaprootPubkeyGen}; + use simplicityhl_core::{LIQUID_TESTNET_TEST_ASSET_ID_STR, create_p2tr_address}; + + use super::*; + + #[derive(Debug, Clone, bincode::Encode, bincode::Decode, PartialEq)] + pub struct OptionsArguments { + pub start_time: u32, + pub expiry_time: u32, + pub collateral_per_contract: u64, + pub settlement_per_contract: u64, + pub collateral_asset_id_hex_le: String, + pub settlement_asset_id_hex_le: String, + pub option_token_asset_id_hex_le: String, + pub grantor_token_asset_id_hex_le: String, + } + + impl Default for OptionsArguments { + fn default() -> Self { + Self { + start_time: 0, + expiry_time: 0, + collateral_per_contract: 0, + settlement_per_contract: 0, + collateral_asset_id_hex_le: "00".repeat(32), + option_token_asset_id_hex_le: "00".repeat(32), + grantor_token_asset_id_hex_le: "00".repeat(32), + settlement_asset_id_hex_le: "00".repeat(32), + } + } + } + + impl simplicityhl_core::Encodable for OptionsArguments {} + + fn load_mock() -> Store { + Store { + store: sled::Config::new().temporary(true).open().expect("expected store"), + } + } + + fn get_mocked_data() -> anyhow::Result<(OptionsArguments, TaprootPubkeyGen)> { + let args = OptionsArguments { + start_time: 10, + expiry_time: 50, + collateral_per_contract: 100, + settlement_per_contract: 1000, + collateral_asset_id_hex_le: elements::AssetId::LIQUID_BTC.to_string(), + settlement_asset_id_hex_le: LIQUID_TESTNET_TEST_ASSET_ID_STR.to_string(), + option_token_asset_id_hex_le: elements::AssetId::LIQUID_BTC.to_string(), + grantor_token_asset_id_hex_le: elements::AssetId::LIQUID_BTC.to_string(), + }; + + let options_taproot_pubkey_gen = + TaprootPubkeyGen::from(&args, &AddressParams::LIQUID_TESTNET, &get_options_address)?; + + Ok((args, options_taproot_pubkey_gen)) + } + + pub fn get_options_address( + x_only_public_key: &XOnlyPublicKey, + arguments: &OptionsArguments, + params: &'static AddressParams, + ) -> anyhow::Result
{ + Ok(create_p2tr_address( + get_options_program(&contracts::build_arguments::OptionsArguments { + start_time: arguments.start_time, + expiry_time: arguments.expiry_time, + collateral_per_contract: arguments.collateral_per_contract, + settlement_per_contract: arguments.settlement_per_contract, + collateral_asset_id_hex_le: arguments.collateral_asset_id_hex_le.clone(), + settlement_asset_id_hex_le: arguments.settlement_asset_id_hex_le.clone(), + option_token_asset_id_hex_le: arguments.option_token_asset_id_hex_le.clone(), + grantor_token_asset_id_hex_le: arguments.grantor_token_asset_id_hex_le.clone(), + })? + .commit() + .cmr(), + x_only_public_key, + params, + )) + } + + #[test] + fn test_sled_serialize_deserialize() -> anyhow::Result<()> { + let store = load_mock(); + + let (args, options_taproot_pubkey_gen) = get_mocked_data()?; + + store.import_arguments( + &options_taproot_pubkey_gen.to_string(), + &args.to_hex()?, + &AddressParams::LIQUID_TESTNET, + &get_options_address, + )?; + + let retrieved = store.get_arguments::(&options_taproot_pubkey_gen.to_string())?; + + assert_eq!(args, retrieved); + + Ok(()) + } + + #[test] + fn test_sled_import_export_roundtrip() -> anyhow::Result<()> { + let store = load_mock(); + + let (args, options_taproot_pubkey_gen) = get_mocked_data()?; + + store.import_arguments( + &options_taproot_pubkey_gen.to_string(), + &args.to_hex()?, + &AddressParams::LIQUID_TESTNET, + &get_options_address, + )?; + + let exported_hex = store.export_arguments(&options_taproot_pubkey_gen.to_string())?; + + assert_eq!(exported_hex, args.to_hex()?); + + Ok(()) + } + + #[test] + fn test_sled_export_get_consistency() -> anyhow::Result<()> { + let store = load_mock(); + + let (args, options_taproot_pubkey_gen) = get_mocked_data()?; + + store.import_arguments( + &options_taproot_pubkey_gen.to_string(), + &args.to_hex()?, + &AddressParams::LIQUID_TESTNET, + &get_options_address, + )?; + + let exported_hex = store.export_arguments(&options_taproot_pubkey_gen.to_string())?; + let exported_bytes = hex::decode(&exported_hex)?; + let decoded_from_export: OptionsArguments = Encodable::decode(&exported_bytes)?; + + let retrieved = store.get_arguments::(&options_taproot_pubkey_gen.to_string())?; + + assert_eq!(decoded_from_export, retrieved); + assert_eq!(retrieved, args); + + Ok(()) + } +} diff --git a/crates/dex-cli/src/common/types.rs b/crates/dex-cli/src/common/types.rs new file mode 100644 index 0000000..76762c3 --- /dev/null +++ b/crates/dex-cli/src/common/types.rs @@ -0,0 +1,80 @@ +use crate::contract_handlers::maker_init::InnerDcdInitParams; +use clap::Args; +use contracts_adapter::dcd::COLLATERAL_ASSET_ID; +use simplicityhl_core::{AssetEntropyHex, AssetIdHex}; + +/// Represents either three asset IDs or three asset entropies as provided on the CLI. +/// This is intended to be parsed by a custom `clap` value parser (placeholder below). +#[derive(Debug, Clone, PartialEq)] +pub enum DcdCliAssets { + /// Already-constructed asset IDs (little-endian hex strings). + AssetIds { + filler_token_asset_id_hex_le: AssetIdHex, + grantor_collateral_token_asset_id_hex_le: AssetIdHex, + grantor_settlement_token_asset_id_hex_le: AssetIdHex, + settlement_token_asset_id_hex_le: AssetIdHex, + }, + /// Entropies from which asset IDs will be derived. + Entropies { + filler_token_entropy_hex: AssetEntropyHex, + grantor_collateral_token_entropy_hex: AssetEntropyHex, + grantor_settlement_token_entropy_hex: AssetEntropyHex, + settlement_token_asset_id_hex_le: AssetEntropyHex, + }, +} + +#[derive(Debug, Args)] +pub struct InitOrderArgs { + /// Taker funding start time as unix timestamp (seconds). + #[arg(long = "taker-funding-start-time")] + taker_funding_start_time: u32, + /// Taker funding end time as unix timestamp (seconds). + #[arg(long = "taker-funding-end-time")] + taker_funding_end_time: u32, + /// Contract expiry time as unix timestamp (seconds). + #[arg(long = "contract-expiry-time")] + contract_expiry_time: u32, + /// Early termination deadline as unix timestamp (seconds). + #[arg(long = "early-termination-end-time")] + early_termination_end_time: u32, + /// Settlement height used for final settlement. + #[arg(long = "settlement-height")] + settlement_height: u32, + /// Principal collateral amount in minimal collateral units. + #[arg(long = "principal-collateral-amount")] + principal_collateral_amount: u64, + /// Incentive fee in basis points (1 bp = 0.01%). + #[arg(long = "incentive-basis-points")] + incentive_basis_points: u64, + /// Filler tokens per principal collateral unit. + #[arg(long = "filler-per-principal-collateral")] + filler_per_principal_collateral: u64, + /// Strike price for the contract (minimal price asset units). + #[arg(long = "strike-price")] + strike_price: u64, + /// Settlement asset entropy as a hex string to be used for this order. + #[arg(long = "settlement-asset-entropy")] + settlement_asset_entropy: String, + /// Oracle public key to use for this init. + #[arg(long = "oracle-pubkey")] + oracle_public_key: String, +} + +impl From for InnerDcdInitParams { + fn from(args: InitOrderArgs) -> Self { + InnerDcdInitParams { + taker_funding_start_time: args.taker_funding_start_time, + taker_funding_end_time: args.taker_funding_end_time, + contract_expiry_time: args.contract_expiry_time, + early_termination_end_time: args.early_termination_end_time, + settlement_height: args.settlement_height, + principal_collateral_amount: args.principal_collateral_amount, + incentive_basis_points: args.incentive_basis_points, + filler_per_principal_collateral: args.filler_per_principal_collateral, + strike_price: args.strike_price, + collateral_asset_id: COLLATERAL_ASSET_ID.to_string(), + settlement_asset_entropy: args.settlement_asset_entropy, + oracle_public_key: args.oracle_public_key, + } + } +} diff --git a/crates/dex-cli/src/common/utils.rs b/crates/dex-cli/src/common/utils.rs new file mode 100644 index 0000000..a7adbcd --- /dev/null +++ b/crates/dex-cli/src/common/utils.rs @@ -0,0 +1,32 @@ +use elements::hex::ToHex; +use hex::FromHex; +use simplicityhl::elements::AssetId; +use simplicityhl_core::broadcast_tx; +use std::io::Write; + +pub const DEFAULT_CLIENT_TIMEOUT_SECS: u64 = 10; + +pub(crate) fn write_into_stdout + std::fmt::Debug>(text: T) -> std::io::Result { + let mut output = text.as_ref().to_string(); + output.push('\n'); + std::io::stdout().write(output.as_bytes()) +} + +pub(crate) fn broadcast_tx_inner(tx: &simplicityhl::elements::Transaction) -> crate::error::Result { + broadcast_tx(tx).map_err(|err| crate::error::CliError::Broadcast(err.to_string())) +} + +pub(crate) fn decode_hex(str: impl AsRef<[u8]>) -> crate::error::Result> { + let str_to_convert = str.as_ref(); + hex::decode(str_to_convert).map_err(|err| crate::error::CliError::FromHex(err, str_to_convert.to_hex())) +} + +pub(crate) fn entropy_to_asset_id(el: impl AsRef<[u8]>) -> crate::error::Result { + use simplicity::hashes::sha256; + let el = el.as_ref(); + let mut asset_entropy_bytes = + <[u8; 32]>::from_hex(el).map_err(|err| crate::error::CliError::FromHex(err, el.to_hex()))?; + asset_entropy_bytes.reverse(); + let midstate = sha256::Midstate::from_byte_array(asset_entropy_bytes); + Ok(AssetId::from_entropy(midstate)) +} diff --git a/crates/dex-cli/src/contract_handlers/address.rs b/crates/dex-cli/src/contract_handlers/address.rs new file mode 100644 index 0000000..4d07c99 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/address.rs @@ -0,0 +1,14 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use elements::bitcoin::XOnlyPublicKey; +use simplicityhl::elements::{Address, AddressParams}; +use simplicityhl_core::get_p2pk_address; + +pub fn handle(index: u32) -> crate::error::Result<(XOnlyPublicKey, Address)> { + let settings = Settings::load()?; + let keypair = derive_keypair_from_index(index, &settings.seed_hex); + let public_key = keypair.x_only_public_key().0; + let address = get_p2pk_address(&public_key, &AddressParams::LIQUID_TESTNET) + .map_err(|err| crate::error::CliError::P2pkAddress(err.to_string()))?; + Ok((public_key, address)) +} diff --git a/crates/dex-cli/src/contract_handlers/common.rs b/crates/dex-cli/src/contract_handlers/common.rs new file mode 100644 index 0000000..7b132c7 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/common.rs @@ -0,0 +1,41 @@ +use crate::common::broadcast_tx_inner; +use crate::common::store::utils::{OrderParams, save_order_params_by_event_id}; +use dex_nostr_relay::relay_processor::RelayProcessor; +use elements::bitcoin::hex::DisplayHex; +use nostr::EventId; +use simplicity::elements::Transaction; +use simplicity::elements::pset::serialize::Serialize; + +pub async fn get_order_params( + maker_order_event_id: EventId, + relay_processor: &RelayProcessor, +) -> crate::error::Result { + Ok( + if let Ok(x) = crate::common::store::utils::get_order_params_by_event_id(maker_order_event_id) { + x + } else { + let order = relay_processor.get_order_by_id(maker_order_event_id).await?; + save_order_params_by_event_id( + maker_order_event_id, + &order.dcd_taproot_pubkey_gen, + order.dcd_arguments.clone(), + )?; + OrderParams { + taproot_pubkey_gen: order.dcd_taproot_pubkey_gen, + dcd_args: order.dcd_arguments, + } + }, + ) +} + +/// Broadcasts created tx +/// +/// Has to be used with blocking async context to perform properly or just use only sync context. +pub fn broadcast_or_get_raw_tx(is_offline: bool, transaction: &Transaction) -> crate::error::Result<()> { + if is_offline { + println!("Raw Tx: {}", transaction.serialize().to_lower_hex_string()); + } else { + broadcast_tx_inner(transaction)?; + } + Ok(()) +} diff --git a/crates/dex-cli/src/contract_handlers/faucet.rs b/crates/dex-cli/src/contract_handlers/faucet.rs new file mode 100644 index 0000000..f9af397 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/faucet.rs @@ -0,0 +1,158 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::common::store::Store; +use crate::contract_handlers::common::broadcast_or_get_raw_tx; +use contracts_adapter::basic::{IssueAssetResponse, ReissueAssetResponse}; +use simplicity::elements::OutPoint; +use simplicity::hashes::sha256::Midstate; +use simplicityhl::elements::{AddressParams, Txid}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, derive_public_blinder_key}; +use tokio::task; + +pub async fn create_asset( + account_index: u32, + asset_name: String, + fee_utxo: OutPoint, + fee_amount: u64, + issue_amount: u64, + is_offline: bool, +) -> crate::error::Result { + task::spawn_blocking(move || { + create_asset_sync( + account_index, + asset_name, + fee_utxo, + fee_amount, + issue_amount, + is_offline, + ) + }) + .await? +} + +fn create_asset_sync( + account_index: u32, + asset_name: String, + fee_utxo: OutPoint, + fee_amount: u64, + issue_amount: u64, + is_offline: bool, +) -> crate::error::Result { + let store = Store::load()?; + + if store.is_exist(&asset_name)? { + return Err(crate::error::CliError::AssetNameExists { name: asset_name }); + } + + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + let blinding_key = derive_public_blinder_key(); + + let IssueAssetResponse { + tx: transaction, + asset_id, + reissuance_asset_id, + asset_entropy, + } = contracts_adapter::basic::issue_asset( + &keypair, + &blinding_key, + fee_utxo, + issue_amount, + fee_amount, + &AddressParams::LIQUID_TESTNET, + LIQUID_TESTNET_BITCOIN_ASSET, + *LIQUID_TESTNET_GENESIS, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + println!( + "Test token asset entropy: '{asset_entropy}', asset_id: '{asset_id}', \ + reissue_asset_id: '{reissuance_asset_id}'" + ); + broadcast_or_get_raw_tx(is_offline, &transaction)?; + store.insert_value(asset_name, asset_entropy.as_bytes())?; + + Ok(transaction.txid()) +} + +pub async fn mint_asset( + account_index: u32, + asset_name: String, + reissue_asset_utxo: OutPoint, + fee_utxo: OutPoint, + reissue_amount: u64, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result { + task::spawn_blocking(move || { + mint_asset_sync( + account_index, + asset_name, + reissue_asset_utxo, + fee_utxo, + reissue_amount, + fee_amount, + is_offline, + ) + }) + .await? +} + +fn mint_asset_sync( + account_index: u32, + asset_name: String, + reissue_asset_utxo: OutPoint, + fee_utxo: OutPoint, + reissue_amount: u64, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result { + let store = Store::load()?; + + let Some(asset_entropy) = store.get_value(&asset_name)? else { + return Err(crate::error::CliError::AssetNameExists { name: asset_name }); + }; + + let asset_entropy = String::from_utf8(asset_entropy.to_vec()) + .map_err(|err| crate::error::CliError::Custom(format!("Failed to convert bytes to string, err: {err}")))?; + let asset_entropy = entropy_to_midstate(&asset_entropy)?; + + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let blinding_key = derive_public_blinder_key(); + let ReissueAssetResponse { + tx: transaction, + asset_id, + reissuance_asset_id, + } = contracts_adapter::basic::reissue_asset( + &keypair, + &blinding_key, + reissue_asset_utxo, + fee_utxo, + reissue_amount, + fee_amount, + asset_entropy, + &AddressParams::LIQUID_TESTNET, + LIQUID_TESTNET_BITCOIN_ASSET, + *LIQUID_TESTNET_GENESIS, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + println!("Minting asset: '{asset_id}', Reissue asset id: '{reissuance_asset_id}'"); + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(transaction.txid()) +} + +pub fn entropy_to_midstate(el: impl AsRef<[u8]>) -> crate::error::Result { + use elements::hex::ToHex; + use hex::FromHex; + use simplicity::hashes::sha256; + let el = el.as_ref(); + let mut asset_entropy_bytes = + <[u8; 32]>::from_hex(el).map_err(|err| crate::error::CliError::FromHex(err, el.to_hex()))?; + asset_entropy_bytes.reverse(); + let midstate = sha256::Midstate::from_byte_array(asset_entropy_bytes); + Ok(midstate) +} diff --git a/crates/dex-cli/src/contract_handlers/maker_funding.rs b/crates/dex-cli/src/contract_handlers/maker_funding.rs new file mode 100644 index 0000000..671b77f --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/maker_funding.rs @@ -0,0 +1,202 @@ +use crate::common::decode_hex; +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::common::store::SledError; +use crate::contract_handlers::common::broadcast_or_get_raw_tx; +use contracts::DCDArguments; +use contracts_adapter::dcd::{ + AssetEntropyProcessed, BaseContractContext, COLLATERAL_ASSET_ID, CreationContext, DcdContractContext, DcdManager, + MakerFundingContext, raw_asset_entropy_bytes_to_midstate, +}; +use dex_nostr_relay::relay_processor::OrderPlaceEventTags; +use elements::bitcoin::secp256k1; +use simplicity::elements::OutPoint; +use simplicityhl::elements::{AddressParams, AssetId, Txid}; +use simplicityhl_core::{ + AssetEntropyHex, LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, TaprootPubkeyGen, derive_public_blinder_key, +}; +use tokio::task; +use tracing::instrument; + +#[derive(Debug)] +pub struct ProcessedArgs { + keypair: secp256k1::Keypair, + dcd_arguments: DCDArguments, + dcd_taproot_pubkey_gen: String, + filler_token_entropy: AssetEntropyHex, + grantor_collateral_token_entropy: AssetEntropyHex, + grantor_settlement_token_entropy: AssetEntropyHex, +} + +#[derive(Debug)] +pub struct ArgsToSave { + taproot_pubkey_gen: TaprootPubkeyGen, + dcd_arguments: DCDArguments, +} + +#[derive(Debug)] +pub struct Utxos { + pub filler_token: OutPoint, + pub grantor_collateral_token: OutPoint, + pub grantor_settlement_token: OutPoint, + pub settlement_asset: OutPoint, + pub fee: OutPoint, +} + +impl ProcessedArgs { + pub fn extract_event(&self) -> OrderPlaceEventTags { + let convert_entropy_to_asset_id = |x: &str| { + let x = hex::decode(x).unwrap(); + let token_entropy = contracts_adapter::dcd::convert_bytes_to_asset_entropy(x).unwrap(); + let AssetEntropyProcessed { + entropy: filler_token_asset_entropy, + reversed_bytes: _filler_reversed_bytes, + } = raw_asset_entropy_bytes_to_midstate(token_entropy); + + AssetId::from_entropy(filler_token_asset_entropy) + }; + + let filler_asset_id = convert_entropy_to_asset_id(&self.filler_token_entropy); + let grantor_collateral_asset_id = convert_entropy_to_asset_id(&self.grantor_collateral_token_entropy); + let grantor_settlement_asset_id = convert_entropy_to_asset_id(&self.grantor_settlement_token_entropy); + let settlement_asset_id = convert_entropy_to_asset_id(&self.dcd_arguments.settlement_asset_id_hex_le); + let collateral_asset_id = COLLATERAL_ASSET_ID; + + OrderPlaceEventTags { + dcd_arguments: self.dcd_arguments.clone(), + dcd_taproot_pubkey_gen: self.dcd_taproot_pubkey_gen.clone(), + filler_asset_id, + grantor_collateral_asset_id, + grantor_settlement_asset_id, + settlement_asset_id, + collateral_asset_id, + } + } +} + +#[instrument(level = "debug", skip_all, err)] +pub fn process_args( + account_index: u32, + dcd_taproot_pubkey_gen: impl AsRef, +) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let taproot_pubkey_gen = dcd_taproot_pubkey_gen.as_ref().to_string(); + + let args = { + let dcd_args = crate::common::store::utils::get_dcd_args(&taproot_pubkey_gen)?; + let filler_token_entropy = crate::common::store::utils::get_filler_token_entropy(&taproot_pubkey_gen)?; + let grantor_collateral_token_entropy = + crate::common::store::utils::get_grantor_collateral_token_entropy(&taproot_pubkey_gen)?; + let grantor_settlement_token_entropy = + crate::common::store::utils::get_grantor_settlement_token_entropy(&taproot_pubkey_gen)?; + + ProcessedArgs { + keypair, + dcd_arguments: dcd_args, + dcd_taproot_pubkey_gen: taproot_pubkey_gen, + filler_token_entropy, + grantor_collateral_token_entropy, + grantor_settlement_token_entropy, + } + }; + Ok(args) +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? +} + +#[instrument(level = "debug", skip_all, err)] +fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + filler_token_entropy, + grantor_collateral_token_entropy, + grantor_settlement_token_entropy, + }: ProcessedArgs, + Utxos { + filler_token: filler_token_utxo, + grantor_collateral_token: grantor_collateral_token_utxo, + grantor_settlement_token: grantor_settlement_token_utxo, + settlement_asset: settlement_asset_utxo, + fee: fee_utxo, + }: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + let filler_token_info = (filler_token_utxo, decode_hex(filler_token_entropy)?); + let grantor_collateral_token_info = ( + grantor_collateral_token_utxo, + decode_hex(grantor_collateral_token_entropy)?, + ); + let grantor_settlement_token_info = ( + grantor_settlement_token_utxo, + decode_hex(grantor_settlement_token_entropy)?, + ); + + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + + let transaction = DcdManager::maker_funding( + &CreationContext { + keypair, + blinding_key: derive_public_blinder_key(), + }, + MakerFundingContext { + filler_token_info, + grantor_collateral_token_info, + grantor_settlement_token_info, + settlement_asset_utxo, + fee_utxo, + fee_amount, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) +} + +pub fn save_args_to_cache( + ArgsToSave { + taproot_pubkey_gen, + dcd_arguments, + }: &ArgsToSave, +) -> crate::error::Result<()> { + crate::common::store::utils::save_dcd_args(taproot_pubkey_gen, dcd_arguments)?; + Ok(()) +} diff --git a/crates/dex-cli/src/contract_handlers/maker_init.rs b/crates/dex-cli/src/contract_handlers/maker_init.rs new file mode 100644 index 0000000..40517e8 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/maker_init.rs @@ -0,0 +1,184 @@ +use crate::common::entropy_to_asset_id; +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::contract_handlers::common::broadcast_or_get_raw_tx; +use contracts::DCDArguments; +use contracts_adapter::dcd::{ + BaseContractContext, CreationContext, DcdInitParams, DcdInitResponse, DcdManager, FillerTokenEntropyHex, + GrantorCollateralAssetEntropyHex, GrantorSettlementAssetEntropyHex, MakerInitContext, +}; +use elements::bitcoin::secp256k1; +use simplicity::elements::OutPoint; +use simplicityhl::elements::{AddressParams, Txid}; +use simplicityhl_core::{ + AssetEntropyHex, AssetIdHex, LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, TaprootPubkeyGen, + derive_public_blinder_key, +}; +use tokio::task; +use tracing::instrument; + +#[derive(Debug)] +pub struct InnerDcdInitParams { + pub taker_funding_start_time: u32, + pub taker_funding_end_time: u32, + pub contract_expiry_time: u32, + pub early_termination_end_time: u32, + pub settlement_height: u32, + pub principal_collateral_amount: u64, + pub incentive_basis_points: u64, + pub filler_per_principal_collateral: u64, + pub strike_price: u64, + pub collateral_asset_id: AssetIdHex, + pub settlement_asset_entropy: AssetEntropyHex, + pub oracle_public_key: String, +} + +#[derive(Debug)] +pub struct ProcessedArgs { + keypair: secp256k1::Keypair, + dcd_init_params: DcdInitParams, +} + +pub struct ArgsToSave { + pub filler_token_entropy: FillerTokenEntropyHex, + pub grantor_collateral_token_entropy: GrantorCollateralAssetEntropyHex, + pub grantor_settlement_token_entropy: GrantorSettlementAssetEntropyHex, + pub taproot_pubkey: TaprootPubkeyGen, + pub dcd_args: DCDArguments, +} + +#[derive(Debug)] +pub struct Utxos { + pub first: OutPoint, + pub second: OutPoint, + pub third: OutPoint, +} + +impl TryInto for InnerDcdInitParams { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + Ok(DcdInitParams { + taker_funding_start_time: self.taker_funding_start_time, + taker_funding_end_time: self.taker_funding_end_time, + contract_expiry_time: self.contract_expiry_time, + early_termination_end_time: self.early_termination_end_time, + settlement_height: self.settlement_height, + principal_collateral_amount: self.principal_collateral_amount, + incentive_basis_points: self.incentive_basis_points, + filler_per_principal_collateral: self.filler_per_principal_collateral, + strike_price: self.strike_price, + collateral_asset_id: self.collateral_asset_id, + settlement_asset_id: entropy_to_asset_id(self.settlement_asset_entropy)?.to_string(), + oracle_public_key: self.oracle_public_key.clone(), + // TODO(Illia): replace with actual data + fee_script_hash: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + fee_basis_points: 0, + }) + } +} + +#[instrument(level = "debug", skip_all, err)] +pub fn process_args(account_index: u32, dcd_init_params: InnerDcdInitParams) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let dcd_init_params: DcdInitParams = dcd_init_params + .try_into() + .map_err(|err: anyhow::Error| crate::error::CliError::InnerDcdConversion(err.to_string()))?; + + Ok(ProcessedArgs { + keypair, + dcd_init_params, + }) +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? +} + +#[instrument(level = "debug", skip_all, err)] +fn handle_sync( + ProcessedArgs { + keypair, + dcd_init_params, + }: ProcessedArgs, + Utxos { + first: first_lbtc_utxo, + second: second_lbtc_utxo, + third: third_lbtc_utxo, + }: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + let DcdInitResponse { + tx: transaction, + filler_token_entropy, + grantor_collateral_token_entropy, + grantor_settlement_token_entropy, + taproot_pubkey_gen, + dcd_args, + } = DcdManager::maker_init( + &CreationContext { + keypair, + blinding_key: derive_public_blinder_key(), + }, + MakerInitContext { + input_utxos: [first_lbtc_utxo, second_lbtc_utxo, third_lbtc_utxo], + dcd_init_params, + fee_amount, + }, + &BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + println!( + "Filler_token_entropy: '{}', grantor_collateral_entropy: '{}', grantor_settlement: '{}', taproot_pubkey: '{}', dcd_args: '{dcd_args:#?}'", + filler_token_entropy, grantor_collateral_token_entropy, grantor_settlement_token_entropy, taproot_pubkey_gen + ); + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + let args_to_save = ArgsToSave { + filler_token_entropy, + grantor_collateral_token_entropy, + grantor_settlement_token_entropy, + taproot_pubkey: taproot_pubkey_gen, + dcd_args, + }; + Ok((transaction.txid(), args_to_save)) +} + +#[instrument(level = "debug", skip_all, err)] +pub fn save_args_to_cache( + ArgsToSave { + filler_token_entropy, + grantor_collateral_token_entropy, + grantor_settlement_token_entropy, + taproot_pubkey, + dcd_args, + }: &ArgsToSave, +) -> crate::error::Result<()> { + crate::common::store::utils::save_filler_token_entropy(taproot_pubkey, filler_token_entropy)?; + crate::common::store::utils::save_grantor_collateral_token_entropy( + taproot_pubkey, + grantor_collateral_token_entropy, + )?; + crate::common::store::utils::save_grantor_settlement_token_entropy( + taproot_pubkey, + grantor_settlement_token_entropy, + )?; + crate::common::store::utils::save_dcd_args(taproot_pubkey, dcd_args)?; + Ok(()) +} diff --git a/crates/dex-cli/src/contract_handlers/maker_settlement.rs b/crates/dex-cli/src/contract_handlers/maker_settlement.rs new file mode 100644 index 0000000..c226547 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/maker_settlement.rs @@ -0,0 +1,150 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::common::store::SledError; +use crate::common::store::utils::OrderParams; +use crate::contract_handlers::common::{broadcast_or_get_raw_tx, get_order_params}; +use contracts::DCDArguments; +use contracts_adapter::dcd::{ + BaseContractContext, CommonContext, DcdContractContext, DcdManager, MakerSettlementContext, +}; +use dex_nostr_relay::relay_processor::RelayProcessor; +use elements::bitcoin::secp256k1; +use nostr::EventId; +use simplicity::elements::OutPoint; +use simplicityhl::elements::{AddressParams, Txid}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, TaprootPubkeyGen}; +use tokio::task; +use tracing::instrument; + +#[derive(Debug)] +pub struct ProcessedArgs { + keypair: secp256k1::Keypair, + dcd_arguments: DCDArguments, + dcd_taproot_pubkey_gen: String, + price_at_current_block_height: u64, + oracle_signature: String, + grantor_amount_to_burn: u64, +} + +#[derive(Debug)] +pub struct Utxos { + pub grantor_collateral_token: OutPoint, + pub grantor_settlement_token: OutPoint, + pub fee: OutPoint, + pub asset: OutPoint, +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn process_args( + account_index: u32, + price_at_current_block_height: u64, + oracle_signature: String, + grantor_amount_to_burn: u64, + maker_order_event_id: EventId, + relay_processor: &RelayProcessor, +) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let order_params: OrderParams = get_order_params(maker_order_event_id, relay_processor).await?; + + Ok(ProcessedArgs { + keypair, + dcd_arguments: order_params.dcd_args, + dcd_taproot_pubkey_gen: order_params.taproot_pubkey_gen, + price_at_current_block_height, + oracle_signature, + grantor_amount_to_burn, + }) +} +#[derive(Debug)] +pub struct ArgsToSave { + taproot_pubkey_gen: TaprootPubkeyGen, + dcd_arguments: DCDArguments, +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + // offload blocking/contracts work, like in split_utxo::handle + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? +} + +#[instrument(level = "debug", skip_all, err)] +fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + price_at_current_block_height, + oracle_signature, + grantor_amount_to_burn, + }: ProcessedArgs, + Utxos { + grantor_collateral_token: grantor_collateral_token_utxo, + grantor_settlement_token: grantor_settlement_token_utxo, + fee: fee_utxo, + asset: asset_utxo, + }: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + let transaction = DcdManager::maker_settlement( + &CommonContext { keypair }, + MakerSettlementContext { + asset_utxo, + grantor_collateral_token_utxo, + grantor_settlement_token_utxo, + fee_utxo, + fee_amount, + price_at_current_block_height, + oracle_signature, + grantor_amount_to_burn, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) +} + +pub fn save_args_to_cache( + ArgsToSave { + taproot_pubkey_gen, + dcd_arguments, + }: &ArgsToSave, +) -> crate::error::Result<()> { + crate::common::store::utils::save_dcd_args(taproot_pubkey_gen, dcd_arguments)?; + Ok(()) +} diff --git a/crates/dex-cli/src/contract_handlers/maker_termination_collateral.rs b/crates/dex-cli/src/contract_handlers/maker_termination_collateral.rs new file mode 100644 index 0000000..b684e55 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/maker_termination_collateral.rs @@ -0,0 +1,136 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::common::store::SledError; +use crate::common::store::utils::OrderParams; +use crate::contract_handlers::common::{broadcast_or_get_raw_tx, get_order_params}; +use contracts::DCDArguments; +use contracts_adapter::dcd::{ + BaseContractContext, CommonContext, DcdContractContext, DcdManager, MakerTerminationCollateralContext, +}; +use dex_nostr_relay::relay_processor::RelayProcessor; +use elements::bitcoin::secp256k1; +use nostr::EventId; +use simplicity::elements::OutPoint; +use simplicityhl::elements::{AddressParams, Txid}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, TaprootPubkeyGen}; +use tokio::task; +use tracing::instrument; + +#[derive(Debug)] +pub struct ProcessedArgs { + keypair: secp256k1::Keypair, + dcd_arguments: DCDArguments, + dcd_taproot_pubkey_gen: String, + grantor_collateral_amount_to_burn: u64, +} + +#[derive(Debug)] +pub struct Utxos { + pub grantor_collateral_token: OutPoint, + pub fee: OutPoint, + pub collateral_token: OutPoint, +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn process_args( + account_index: u32, + grantor_collateral_amount_to_burn: u64, + maker_order_event_id: EventId, + relay_processor: &RelayProcessor, +) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let order_params: OrderParams = get_order_params(maker_order_event_id, relay_processor).await?; + + Ok(ProcessedArgs { + keypair, + dcd_arguments: order_params.dcd_args, + dcd_taproot_pubkey_gen: order_params.taproot_pubkey_gen, + grantor_collateral_amount_to_burn, + }) +} +#[derive(Debug)] +pub struct ArgsToSave { + taproot_pubkey_gen: TaprootPubkeyGen, + dcd_arguments: DCDArguments, +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? +} + +#[instrument(level = "debug", skip_all, err)] +fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + grantor_collateral_amount_to_burn, + }: ProcessedArgs, + Utxos { + grantor_collateral_token: grantor_collateral_token_utxo, + fee: fee_utxo, + collateral_token: collateral_token_utxo, + }: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + let transaction = DcdManager::maker_collateral_termination( + &CommonContext { keypair }, + MakerTerminationCollateralContext { + collateral_token_utxo, + grantor_collateral_token_utxo, + fee_utxo, + fee_amount, + grantor_collateral_amount_to_burn, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) +} + +pub fn save_args_to_cache( + ArgsToSave { + taproot_pubkey_gen, + dcd_arguments, + }: &ArgsToSave, +) -> crate::error::Result<()> { + crate::common::store::utils::save_dcd_args(taproot_pubkey_gen, dcd_arguments)?; + Ok(()) +} diff --git a/crates/dex-cli/src/contract_handlers/maker_termination_settlement.rs b/crates/dex-cli/src/contract_handlers/maker_termination_settlement.rs new file mode 100644 index 0000000..676cc73 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/maker_termination_settlement.rs @@ -0,0 +1,137 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::common::store::SledError; +use crate::common::store::utils::OrderParams; +use crate::contract_handlers::common::{broadcast_or_get_raw_tx, get_order_params}; +use contracts::DCDArguments; +use contracts_adapter::dcd::{ + BaseContractContext, CommonContext, DcdContractContext, DcdManager, MakerTerminationSettlementContext, +}; +use dex_nostr_relay::relay_processor::RelayProcessor; +use elements::bitcoin::secp256k1; +use nostr::EventId; +use simplicity::elements::OutPoint; +use simplicityhl::elements::{AddressParams, Txid}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, TaprootPubkeyGen}; +use tokio::task; +use tracing::instrument; + +#[derive(Debug)] +pub struct ProcessedArgs { + keypair: secp256k1::Keypair, + dcd_arguments: DCDArguments, + dcd_taproot_pubkey_gen: String, + grantor_settlement_amount_to_burn: u64, +} + +#[derive(Debug)] +pub struct ArgsToSave { + taproot_pubkey_gen: TaprootPubkeyGen, + dcd_arguments: DCDArguments, +} + +#[derive(Debug)] +pub struct Utxos { + pub fee: OutPoint, + pub settlement_asset: OutPoint, + pub grantor_settlement_token: OutPoint, +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn process_args( + account_index: u32, + grantor_settlement_amount_to_burn: u64, + maker_order_event_id: EventId, + relay_processor: &RelayProcessor, +) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let order_params: OrderParams = get_order_params(maker_order_event_id, relay_processor).await?; + + Ok(ProcessedArgs { + keypair, + dcd_arguments: order_params.dcd_args, + dcd_taproot_pubkey_gen: order_params.taproot_pubkey_gen, + grantor_settlement_amount_to_burn, + }) +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? +} + +#[instrument(level = "debug", skip_all, err)] +fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + grantor_settlement_amount_to_burn, + }: ProcessedArgs, + Utxos { + fee: fee_utxo, + settlement_asset: settlement_asset_utxo, + grantor_settlement_token: grantor_settlement_token_utxo, + }: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + let transaction = DcdManager::maker_settlement_termination( + &CommonContext { keypair }, + MakerTerminationSettlementContext { + settlement_asset_utxo, + grantor_settlement_token_utxo, + fee_utxo, + fee_amount, + grantor_settlement_amount_to_burn, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) +} + +pub fn save_args_to_cache( + ArgsToSave { + taproot_pubkey_gen, + dcd_arguments, + }: &ArgsToSave, +) -> crate::error::Result<()> { + crate::common::store::utils::save_dcd_args(taproot_pubkey_gen, dcd_arguments)?; + Ok(()) +} diff --git a/crates/dex-cli/src/contract_handlers/merge_tokens.rs b/crates/dex-cli/src/contract_handlers/merge_tokens.rs new file mode 100644 index 0000000..cdbe854 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/merge_tokens.rs @@ -0,0 +1,319 @@ +use crate::common::broadcast_tx_inner; +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::common::store::SledError; +use crate::common::store::utils::OrderParams; +use crate::contract_handlers::common::get_order_params; +use contracts::DCDArguments; +use contracts_adapter::dcd::{BaseContractContext, CommonContext, DcdContractContext, DcdManager}; +use dex_nostr_relay::relay_processor::RelayProcessor; +use elements::bitcoin::hex::DisplayHex; +use elements::bitcoin::secp256k1; +use nostr::EventId; +use simplicity::elements::OutPoint; +use simplicity::elements::pset::serialize::Serialize; +use simplicityhl::elements::{AddressParams, Txid}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, TaprootPubkeyGen}; +use tracing::instrument; + +#[derive(Debug)] +pub struct ProcessedArgs { + keypair: secp256k1::Keypair, + dcd_arguments: DCDArguments, + dcd_taproot_pubkey_gen: String, +} + +#[derive(Debug)] +pub struct ArgsToSave { + taproot_pubkey_gen: TaprootPubkeyGen, + dcd_arguments: DCDArguments, +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn process_args( + account_index: u32, + maker_order_event_id: EventId, + relay_processor: &RelayProcessor, +) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let order_params: OrderParams = get_order_params(maker_order_event_id, relay_processor).await?; + + Ok(ProcessedArgs { + keypair, + dcd_arguments: order_params.dcd_args, + dcd_taproot_pubkey_gen: order_params.taproot_pubkey_gen, + }) +} +pub mod merge2 { + use super::{ + AddressParams, ArgsToSave, BaseContractContext, CommonContext, DcdContractContext, DcdManager, DisplayHex, + LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, OutPoint, ProcessedArgs, Serialize, SledError, + TaprootPubkeyGen, Txid, broadcast_tx_inner, instrument, + }; + use contracts::MergeBranch; + use contracts_adapter::dcd::MergeTokensContext; + use tokio::task; + + #[derive(Debug)] + pub struct Utxos2 { + pub utxo_1: OutPoint, + pub utxo_2: OutPoint, + pub fee: OutPoint, + } + + #[instrument(level = "debug", skip_all, err)] + pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos2, + fee_amount: u64, + is_offline: bool, + ) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? + } + + #[instrument(level = "debug", skip_all, err)] + fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + }: ProcessedArgs, + Utxos2 { utxo_1, utxo_2, fee }: Utxos2, + fee_amount: u64, + is_offline: bool, + ) -> crate::error::Result<(Txid, ArgsToSave)> { + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + let transaction = DcdManager::merge_tokens( + &CommonContext { keypair }, + MergeTokensContext { + token_utxos: vec![utxo_1, utxo_2], + fee_utxo: fee, + fee_amount, + merge_branch: MergeBranch::Two, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + if is_offline { + println!("{}", transaction.serialize().to_lower_hex_string()); + } else { + println!("Broadcasted txid: {}", broadcast_tx_inner(&transaction)?); + } + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) + } +} +pub mod merge3 { + use super::{ + AddressParams, ArgsToSave, BaseContractContext, CommonContext, DcdContractContext, DcdManager, DisplayHex, + LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, OutPoint, ProcessedArgs, Serialize, SledError, + TaprootPubkeyGen, Txid, broadcast_tx_inner, instrument, + }; + use contracts::MergeBranch; + use contracts_adapter::dcd::MergeTokensContext; + use tokio::task; + + #[derive(Debug)] + pub struct Utxos3 { + pub utxo_1: OutPoint, + pub utxo_2: OutPoint, + pub utxo_3: OutPoint, + pub fee: OutPoint, + } + + #[instrument(level = "debug", skip_all, err)] + pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos3, + fee_amount: u64, + is_offline: bool, + ) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? + } + + #[instrument(level = "debug", skip_all, err)] + fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + }: ProcessedArgs, + Utxos3 { + utxo_1, + utxo_2, + utxo_3, + fee, + }: Utxos3, + fee_amount: u64, + is_offline: bool, + ) -> crate::error::Result<(Txid, ArgsToSave)> { + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + let transaction = DcdManager::merge_tokens( + &CommonContext { keypair }, + MergeTokensContext { + token_utxos: vec![utxo_1, utxo_2, utxo_3], + fee_utxo: fee, + fee_amount, + merge_branch: MergeBranch::Three, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + if is_offline { + println!("{}", transaction.serialize().to_lower_hex_string()); + } else { + println!("Broadcasted txid: {}", broadcast_tx_inner(&transaction)?); + } + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) + } +} +pub mod merge4 { + use super::{ + AddressParams, ArgsToSave, BaseContractContext, CommonContext, DcdContractContext, DcdManager, + LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, OutPoint, ProcessedArgs, SledError, TaprootPubkeyGen, + Txid, instrument, + }; + use crate::contract_handlers::common::broadcast_or_get_raw_tx; + use contracts::MergeBranch; + use contracts_adapter::dcd::MergeTokensContext; + use tokio::task; + + #[derive(Debug)] + pub struct Utxos4 { + pub utxo_1: OutPoint, + pub utxo_2: OutPoint, + pub utxo_3: OutPoint, + pub utxo_4: OutPoint, + pub fee: OutPoint, + } + + #[instrument(level = "debug", skip_all, err)] + pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos4, + fee_amount: u64, + is_offline: bool, + ) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? + } + + #[instrument(level = "debug", skip_all, err)] + fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + }: ProcessedArgs, + Utxos4 { + utxo_1, + utxo_2, + utxo_3, + utxo_4, + fee, + }: Utxos4, + fee_amount: u64, + is_offline: bool, + ) -> crate::error::Result<(Txid, ArgsToSave)> { + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + let transaction = DcdManager::merge_tokens( + &CommonContext { keypair }, + MergeTokensContext { + token_utxos: vec![utxo_1, utxo_2, utxo_3, utxo_4], + fee_utxo: fee, + fee_amount, + merge_branch: MergeBranch::Two, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) + } +} + +pub fn save_args_to_cache( + ArgsToSave { + taproot_pubkey_gen, + dcd_arguments, + }: &ArgsToSave, +) -> crate::error::Result<()> { + crate::common::store::utils::save_dcd_args(taproot_pubkey_gen, dcd_arguments)?; + Ok(()) +} diff --git a/crates/dex-cli/src/contract_handlers/mod.rs b/crates/dex-cli/src/contract_handlers/mod.rs new file mode 100644 index 0000000..b1db9db --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/mod.rs @@ -0,0 +1,14 @@ +pub(crate) mod address; +pub(crate) mod common; +pub(crate) mod faucet; +pub(crate) mod maker_funding; +pub(crate) mod maker_init; +pub(crate) mod maker_settlement; +pub(crate) mod maker_termination_collateral; +pub(crate) mod maker_termination_settlement; +pub(crate) mod merge_tokens; +pub(crate) mod oracle_signature; +pub(crate) mod split_utxo; +pub(crate) mod taker_early_termination; +pub(crate) mod taker_funding; +pub(crate) mod taker_settlement; diff --git a/crates/dex-cli/src/contract_handlers/oracle_signature.rs b/crates/dex-cli/src/contract_handlers/oracle_signature.rs new file mode 100644 index 0000000..15eeed0 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/oracle_signature.rs @@ -0,0 +1,20 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use contracts::oracle_msg; +use elements::bitcoin::secp256k1; +use elements::secp256k1_zkp::Message; +use nostr::prelude::Signature; +use simplicity::elements::secp256k1_zkp::PublicKey; + +pub fn handle( + index: u32, + price_at_current_block_height: u64, + settlement_height: u32, +) -> crate::error::Result<(PublicKey, Message, Signature)> { + let settings = Settings::load()?; + let keypair = derive_keypair_from_index(index, &settings.seed_hex); + let pubkey = keypair.public_key(); + let msg = secp256k1::Message::from_digest_slice(&oracle_msg(settlement_height, price_at_current_block_height))?; + let sig = secp256k1::SECP256K1.sign_schnorr(&msg, &keypair); + Ok((pubkey, msg, sig)) +} diff --git a/crates/dex-cli/src/contract_handlers/split_utxo.rs b/crates/dex-cli/src/contract_handlers/split_utxo.rs new file mode 100644 index 0000000..240accb --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/split_utxo.rs @@ -0,0 +1,44 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::contract_handlers::common::broadcast_or_get_raw_tx; +use simplicityhl::elements::{AddressParams, OutPoint, Txid}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, get_p2pk_address}; +use tokio::task; + +pub async fn handle( + account_index: u32, + split_amount: u64, + fee_utxo: OutPoint, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result { + task::spawn_blocking(move || handle_sync(account_index, split_amount, fee_utxo, fee_amount, is_offline)).await? +} + +fn handle_sync( + account_index: u32, + split_amount: u64, + fee_utxo: OutPoint, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let recipient_addr = get_p2pk_address(&keypair.x_only_public_key().0, &AddressParams::LIQUID_TESTNET).unwrap(); + let transaction = contracts_adapter::basic::split_native_three( + &keypair, + fee_utxo, + &recipient_addr, + split_amount, + fee_amount, + &AddressParams::LIQUID_TESTNET, + LIQUID_TESTNET_BITCOIN_ASSET, + *LIQUID_TESTNET_GENESIS, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(transaction.txid()) +} diff --git a/crates/dex-cli/src/contract_handlers/taker_early_termination.rs b/crates/dex-cli/src/contract_handlers/taker_early_termination.rs new file mode 100644 index 0000000..9009a67 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/taker_early_termination.rs @@ -0,0 +1,137 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::common::store::SledError; +use crate::common::store::utils::OrderParams; +use crate::contract_handlers::common::{broadcast_or_get_raw_tx, get_order_params}; +use contracts::DCDArguments; +use contracts_adapter::dcd::{ + BaseContractContext, CommonContext, DcdContractContext, DcdManager, TakerTerminationEarlyContext, +}; +use dex_nostr_relay::relay_processor::RelayProcessor; +use elements::bitcoin::secp256k1; +use nostr::EventId; +use simplicity::elements::OutPoint; +use simplicityhl::elements::{AddressParams, Txid}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, TaprootPubkeyGen}; +use tokio::task; +use tracing::instrument; + +#[derive(Debug)] +pub struct ProcessedArgs { + keypair: secp256k1::Keypair, + dcd_arguments: DCDArguments, + dcd_taproot_pubkey_gen: String, + filler_token_amount_to_return: u64, +} + +#[derive(Debug)] +pub struct ArgsToSave { + taproot_pubkey_gen: TaprootPubkeyGen, + dcd_arguments: DCDArguments, +} + +#[derive(Debug)] +pub struct Utxos { + pub filler_token: OutPoint, + pub collateral_token: OutPoint, + pub fee: OutPoint, +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn process_args( + account_index: u32, + filler_token_amount_to_return: u64, + maker_order_event_id: EventId, + relay_processor: &RelayProcessor, +) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let order_params: OrderParams = get_order_params(maker_order_event_id, relay_processor).await?; + + Ok(ProcessedArgs { + keypair, + dcd_arguments: order_params.dcd_args, + dcd_taproot_pubkey_gen: order_params.taproot_pubkey_gen, + filler_token_amount_to_return, + }) +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? +} + +#[instrument(level = "debug", skip_all, err)] +fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + filler_token_amount_to_return, + }: ProcessedArgs, + Utxos { + filler_token: filler_token_utxo, + collateral_token: collateral_token_utxo, + fee: fee_utxo, + }: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + let transaction = DcdManager::taker_early_termination( + &CommonContext { keypair }, + TakerTerminationEarlyContext { + filler_token_utxo, + collateral_token_utxo, + fee_utxo, + fee_amount, + filler_token_amount_to_return, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) +} + +pub fn save_args_to_cache( + ArgsToSave { + taproot_pubkey_gen, + dcd_arguments, + }: &ArgsToSave, +) -> crate::error::Result<()> { + crate::common::store::utils::save_dcd_args(taproot_pubkey_gen, dcd_arguments)?; + Ok(()) +} diff --git a/crates/dex-cli/src/contract_handlers/taker_funding.rs b/crates/dex-cli/src/contract_handlers/taker_funding.rs new file mode 100644 index 0000000..997328e --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/taker_funding.rs @@ -0,0 +1,131 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::common::store::SledError; +use crate::common::store::utils::OrderParams; +use crate::contract_handlers::common::{broadcast_or_get_raw_tx, get_order_params}; +use contracts::DCDArguments; +use contracts_adapter::dcd::{BaseContractContext, CommonContext, DcdContractContext, DcdManager, TakerFundingContext}; +use dex_nostr_relay::relay_processor::RelayProcessor; +use elements::bitcoin::secp256k1; +use nostr::EventId; +use simplicity::elements::OutPoint; +use simplicityhl::elements::{AddressParams, Txid}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, TaprootPubkeyGen}; +use tokio::task; +use tracing::instrument; + +#[derive(Debug)] +pub struct ProcessedArgs { + keypair: secp256k1::Keypair, + dcd_arguments: DCDArguments, + dcd_taproot_pubkey_gen: String, + collateral_amount_to_deposit: u64, +} + +#[derive(Debug)] +pub struct ArgsToSave { + taproot_pubkey_gen: TaprootPubkeyGen, + dcd_arguments: DCDArguments, +} + +pub struct Utxos { + pub filler_token_utxo: OutPoint, + pub collateral_token_utxo: OutPoint, +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn process_args( + account_index: u32, + collateral_amount_to_deposit: u64, + maker_order_event_id: EventId, + relay_processor: &RelayProcessor, +) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let order_params: OrderParams = get_order_params(maker_order_event_id, relay_processor).await?; + + Ok(ProcessedArgs { + keypair, + dcd_arguments: order_params.dcd_args, + dcd_taproot_pubkey_gen: order_params.taproot_pubkey_gen, + collateral_amount_to_deposit, + }) +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? +} + +#[instrument(level = "debug", skip_all, err)] +fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + collateral_amount_to_deposit, + }: ProcessedArgs, + Utxos { + filler_token_utxo, + collateral_token_utxo, + }: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + let transaction = DcdManager::taker_funding( + &CommonContext { keypair }, + TakerFundingContext { + filler_token_utxo, + collateral_token_utxo, + fee_amount, + collateral_amount_to_deposit, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) +} + +pub fn save_args_to_cache( + ArgsToSave { + taproot_pubkey_gen, + dcd_arguments, + }: &ArgsToSave, +) -> crate::error::Result<()> { + crate::common::store::utils::save_dcd_args(taproot_pubkey_gen, dcd_arguments)?; + Ok(()) +} diff --git a/crates/dex-cli/src/contract_handlers/taker_settlement.rs b/crates/dex-cli/src/contract_handlers/taker_settlement.rs new file mode 100644 index 0000000..da83dc6 --- /dev/null +++ b/crates/dex-cli/src/contract_handlers/taker_settlement.rs @@ -0,0 +1,147 @@ +use crate::common::keys::derive_keypair_from_index; +use crate::common::settings::Settings; +use crate::common::store::SledError; +use crate::common::store::utils::OrderParams; +use crate::contract_handlers::common::{broadcast_or_get_raw_tx, get_order_params}; +use contracts::DCDArguments; +use contracts_adapter::dcd::{ + BaseContractContext, CommonContext, DcdContractContext, DcdManager, TakerSettlementContext, +}; +use dex_nostr_relay::relay_processor::RelayProcessor; +use elements::bitcoin::secp256k1; +use nostr::EventId; +use simplicity::elements::OutPoint; +use simplicityhl::elements::{AddressParams, Txid}; +use simplicityhl_core::{LIQUID_TESTNET_BITCOIN_ASSET, LIQUID_TESTNET_GENESIS, TaprootPubkeyGen}; +use tokio::task; +use tracing::instrument; + +#[derive(Debug)] +pub struct ProcessedArgs { + keypair: secp256k1::Keypair, + dcd_arguments: DCDArguments, + dcd_taproot_pubkey_gen: String, + price_at_current_block_height: u64, + filler_amount_to_burn: u64, + oracle_signature: String, +} + +#[derive(Debug)] +pub struct ArgsToSave { + taproot_pubkey_gen: TaprootPubkeyGen, + dcd_arguments: DCDArguments, +} + +#[derive(Debug)] +pub struct Utxos { + pub filler_token: OutPoint, + pub asset: OutPoint, + pub fee: OutPoint, +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn process_args( + account_index: u32, + price_at_current_block_height: u64, + filler_amount_to_burn: u64, + oracle_signature: String, + maker_order_event_id: EventId, + relay_processor: &RelayProcessor, +) -> crate::error::Result { + let settings = Settings::load().map_err(|err| crate::error::CliError::EnvNotSet(err.to_string()))?; + + let keypair = derive_keypair_from_index(account_index, &settings.seed_hex); + + let order_params: OrderParams = get_order_params(maker_order_event_id, relay_processor).await?; + + Ok(ProcessedArgs { + keypair, + dcd_arguments: order_params.dcd_args, + dcd_taproot_pubkey_gen: order_params.taproot_pubkey_gen, + price_at_current_block_height, + filler_amount_to_burn, + oracle_signature, + }) +} + +#[instrument(level = "debug", skip_all, err)] +pub async fn handle( + processed_args: ProcessedArgs, + utxos: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + task::spawn_blocking(move || handle_sync(processed_args, utxos, fee_amount, is_offline)).await? +} + +#[instrument(level = "debug", skip_all, err)] +fn handle_sync( + ProcessedArgs { + keypair, + dcd_arguments, + dcd_taproot_pubkey_gen, + price_at_current_block_height, + filler_amount_to_burn, + oracle_signature, + }: ProcessedArgs, + Utxos { + filler_token: filler_token_utxo, + asset: asset_utxo, + fee: fee_utxo, + }: Utxos, + fee_amount: u64, + is_offline: bool, +) -> crate::error::Result<(Txid, ArgsToSave)> { + tracing::debug!("=== dcd arguments: {:?}", dcd_arguments); + let base_contract_context = BaseContractContext { + address_params: &AddressParams::LIQUID_TESTNET, + lbtc_asset: LIQUID_TESTNET_BITCOIN_ASSET, + genesis_block_hash: *LIQUID_TESTNET_GENESIS, + }; + let dcd_taproot_pubkey_gen = TaprootPubkeyGen::build_from_str( + &dcd_taproot_pubkey_gen, + &dcd_arguments, + base_contract_context.address_params, + &contracts::get_dcd_address, + ) + .map_err(|e| SledError::TapRootGen(e.to_string()))?; + + let transaction = DcdManager::taker_settlement( + &CommonContext { keypair }, + TakerSettlementContext { + asset_utxo, + filler_token_utxo, + fee_utxo, + fee_amount, + price_at_current_block_height, + filler_amount_to_burn, + oracle_signature, + }, + &DcdContractContext { + dcd_taproot_pubkey_gen: dcd_taproot_pubkey_gen.clone(), + dcd_arguments: dcd_arguments.clone(), + base_contract_context, + }, + ) + .map_err(|err| crate::error::CliError::DcdManager(err.to_string()))?; + + broadcast_or_get_raw_tx(is_offline, &transaction)?; + + Ok(( + transaction.txid(), + ArgsToSave { + taproot_pubkey_gen: dcd_taproot_pubkey_gen, + dcd_arguments, + }, + )) +} + +pub fn save_args_to_cache( + ArgsToSave { + taproot_pubkey_gen, + dcd_arguments, + }: &ArgsToSave, +) -> crate::error::Result<()> { + crate::common::store::utils::save_dcd_args(taproot_pubkey_gen, dcd_arguments)?; + Ok(()) +} diff --git a/crates/dex-cli/src/error.rs b/crates/dex-cli/src/error.rs index 19d1340..e5328eb 100644 --- a/crates/dex-cli/src/error.rs +++ b/crates/dex-cli/src/error.rs @@ -1,15 +1,53 @@ -use crate::utils::FileError; - +use crate::common::store::SledError; +use config::ConfigError; use dex_nostr_relay::error::NostrRelayError; +use elements::bitcoin::secp256k1; +use tokio::task::JoinError; pub type Result = core::result::Result; #[derive(thiserror::Error, Debug)] pub enum CliError { - #[error("Occurred error with io, err: {0}")] + #[error("Occurred error with io, err: '{0}'")] Io(#[from] std::io::Error), #[error(transparent)] - File(#[from] FileError), - #[error(transparent)] NostrRelay(#[from] NostrRelayError), + #[error("Occurred error with usage of Dcd manager, err: '{0}'")] + DcdManager(String), + #[error("Configuration error, err: '{0}'")] + Config(#[from] ConfigError), + #[error("Configuration error, err: '{0}'")] + ConfigExtended(String), + #[error("Failed to obtain utxo, '{0}'")] + Utxo(String), + #[error("'{0}', not set in environment or .env")] + EnvNotSet(String), + #[error("Failed to broadcast transaction, err: '{0}'")] + Broadcast(String), + #[error("Failed to obtain P2PK address, err: '{0}'")] + P2pkAddress(String), + #[error(transparent)] + SledError(#[from] SledError), + #[error("Asset name already exists, name: '{name}'")] + AssetNameExists { name: String }, + #[error("Asset name is absent, name: '{name}'")] + AssetNameAbsent { name: String }, + #[error("Failed to covert value from hex, err: '{0}', value: '{1}'")] + FromHex(hex::FromHexError, String), + #[error("Failed to convert dcd inner params into dcd params, err msg: '{0}'")] + InnerDcdConversion(String), + #[error("Expected at least {expected} elements, got {got}")] + InvalidElementsSize { got: usize, expected: usize }, + #[error("Secp256k1 error: '{0}'")] + EcCurve(#[from] secp256k1::Error), + #[error("Failed to create DcdRatioArgs, msg: '{0}'")] + DcdRatioArgs(String), + #[error("Failed to obtain/save value from cache, msg: '{0}'")] + Cache(String), + #[error("Nostr keypair is required for the action, but it's absent")] + NoNostrKeypairListed, + #[error("Failed to join task, err: '{0}'")] + TokioJoinError(#[from] JoinError), + #[error("Occurred error with msg: '{0}'")] + Custom(String), } diff --git a/crates/dex-cli/src/lib.rs b/crates/dex-cli/src/lib.rs index fe25d5b..75a5a6e 100644 --- a/crates/dex-cli/src/lib.rs +++ b/crates/dex-cli/src/lib.rs @@ -1,3 +1,6 @@ +#![warn(clippy::all, clippy::pedantic)] + pub mod cli; +pub mod common; +mod contract_handlers; pub mod error; -mod utils; diff --git a/crates/dex-cli/src/utils.rs b/crates/dex-cli/src/utils.rs deleted file mode 100644 index 69f3599..0000000 --- a/crates/dex-cli/src/utils.rs +++ /dev/null @@ -1,80 +0,0 @@ -use nostr::{Keys, RelayUrl}; -use std::collections::HashSet; -use std::io::BufRead; -use std::str::FromStr; -use std::{io::Write, path::PathBuf}; - -const DEFAULT_RELAYS_FILEPATH: &str = ".simplicity-dex/relays.txt"; -const DEFAULT_KEY_PATH: &str = ".simplicity-dex/keypair.txt"; -pub const DEFAULT_CLIENT_TIMEOUT_SECS: u64 = 10; - -pub fn write_into_stdout + std::fmt::Debug>(text: T) -> std::io::Result { - let mut output = text.as_ref().to_string(); - output.push('\n'); - std::io::stdout().write(output.as_bytes()) -} - -pub fn default_key_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from("../../..")) - .join(DEFAULT_KEY_PATH) -} - -pub fn default_relays_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from("../../..")) - .join(DEFAULT_RELAYS_FILEPATH) -} -#[derive(Debug, thiserror::Error)] -pub enum FileError { - #[error("Unable to parse url: {1}, error: {0}")] - UrlParseError(nostr::types::url::Error, String), - #[error("Got error on reading/writing to file: {1}, error: {0}")] - ProblemWithFile(std::io::Error, PathBuf), - #[error("Incorrect path to the file, please check validity of the path (err: path is not a file), got path: {0}")] - IncorrectPathToFile(PathBuf), - #[error("File is empty, got path: {0}")] - EmptyFile(PathBuf), - #[error("File is empty, got path: {0}")] - KeyParseError(nostr::key::Error, String), -} - -pub fn check_file_existence(path: &str) -> Result { - let path = PathBuf::from(path); - - if path.is_file() { - Ok(path) - } else { - Err(FileError::IncorrectPathToFile(path.clone()).to_string()) - } -} - -pub fn get_valid_urls_from_file(filepath: &PathBuf) -> Result, FileError> { - let file = std::fs::File::open(filepath).map_err(|x| FileError::ProblemWithFile(x, filepath.clone()))?; - let reader = std::io::BufReader::new(file); - let mut set = HashSet::new(); - for x in reader.lines() { - let line = x.map_err(|x| FileError::ProblemWithFile(x, filepath.clone()))?; - match RelayUrl::parse(&line) { - Ok(url) => { - set.insert(url); - } - Err(e) => { - return Err(FileError::UrlParseError(e, line)); - } - } - } - Ok(set.into_iter().collect::>()) -} - -pub fn get_valid_key_from_file(filepath: &PathBuf) -> Result { - let file = std::fs::File::open(filepath).map_err(|x| FileError::ProblemWithFile(x, filepath.clone()))?; - let reader = std::io::BufReader::new(file); - let key = reader - .lines() - .next() - .ok_or_else(|| FileError::EmptyFile(filepath.clone()))? - .map_err(|x| FileError::ProblemWithFile(x, filepath.clone()))?; - let key = Keys::from_str(&key).map_err(|e| FileError::KeyParseError(e, key))?; - Ok(key) -} diff --git a/crates/dex-nostr-relay/Cargo.toml b/crates/dex-nostr-relay/Cargo.toml index 894e5fa..bbb7275 100644 --- a/crates/dex-nostr-relay/Cargo.toml +++ b/crates/dex-nostr-relay/Cargo.toml @@ -1,5 +1,8 @@ [package] name = "dex-nostr-relay" +description = "Dex Nostr Relay communicator implementation" +license = "MIT OR Apache-2.0" + version.workspace = true edition.workspace = true rust-version.workspace = true @@ -8,9 +11,20 @@ readme.workspace = true [dependencies] anyhow = { workspace = true } -tokio = { workspace = true } +bincode = { workspace = true } +chrono = { workspace = true } global-utils = { workspace = true } -nostr-sdk = { workspace = true } +hex = { workspace = true } nostr = { workspace = true } -tracing = { workspace = true } +nostr-sdk = { workspace = true } +contracts-adapter = { workspace = true } +contracts = { workspace = true } +simplicity-lang = { workspace = true } +simplicityhl = { workspace = true } +simplicityhl-core = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +dotenvy = { workspace = true } \ No newline at end of file diff --git a/crates/dex-nostr-relay/src/error.rs b/crates/dex-nostr-relay/src/error.rs index c28e6c2..8b8e1c1 100644 --- a/crates/dex-nostr-relay/src/error.rs +++ b/crates/dex-nostr-relay/src/error.rs @@ -13,6 +13,15 @@ pub enum NostrRelayError { NostrClientFailure(#[from] nostr_sdk::client::Error), #[error("Relay Client requires for operation signature, add key to the Client")] MissingSigner, + #[error("No events found by filter: '{0}'")] + NoEventsFound(String), + #[error("Found many events, but required to be only one with filter: '{0}'")] + NotOnlyOneEventFound(String), + #[error("Failed to encode '{struct_to_encode}', err: `{err}`")] + BincodeEncoding { + err: bincode::error::EncodeError, + struct_to_encode: String, + }, } pub type Result = std::result::Result; diff --git a/crates/dex-nostr-relay/src/handlers/common.rs b/crates/dex-nostr-relay/src/handlers/common.rs new file mode 100644 index 0000000..7653438 --- /dev/null +++ b/crates/dex-nostr-relay/src/handlers/common.rs @@ -0,0 +1,34 @@ +use crate::types::{MakerOrderEvent, OrderReplyEvent}; +use chrono::{DateTime, TimeZone, Utc}; +use nostr::Timestamp; +use nostr_sdk::prelude::Events; + +pub fn filter_maker_order_events(events_to_filter: &Events) -> Vec { + events_to_filter + .iter() + .filter_map(MakerOrderEvent::parse_event) + .collect() +} + +pub fn sort_maker_order_events_by_time(mut events: Vec) -> Vec { + events.sort_by_key(|e| e.time); + events +} + +pub fn filter_order_reply_events(events_to_filter: &Events) -> Vec { + events_to_filter + .iter() + .filter_map(OrderReplyEvent::parse_event) + .collect() +} + +pub fn sort_order_replies_by_time(mut events: Vec) -> Vec { + events.sort_by_key(|e| e.time); + events +} + +pub fn timestamp_to_chrono_utc(time: Timestamp) -> Option> { + chrono::Utc + .timestamp_opt(i64::try_from(time.as_u64()).ok()?, 0) + .single() +} diff --git a/crates/dex-nostr-relay/src/handlers/get_events.rs b/crates/dex-nostr-relay/src/handlers/get_events.rs index 94c5324..03faf0a 100644 --- a/crates/dex-nostr-relay/src/handlers/get_events.rs +++ b/crates/dex-nostr-relay/src/handlers/get_events.rs @@ -1,10 +1,8 @@ pub mod ids { use crate::relay_client::RelayClient; - - use std::collections::{BTreeMap, BTreeSet}; - use nostr::{EventId, Filter}; use nostr_sdk::prelude::Events; + use std::collections::{BTreeMap, BTreeSet}; pub async fn handle(client: &RelayClient, event_id: EventId) -> crate::error::Result { let events = client @@ -22,3 +20,29 @@ pub mod ids { Ok(events) } } + +pub mod order { + use crate::handlers::common::{filter_maker_order_events, sort_maker_order_events_by_time}; + use crate::relay_client::RelayClient; + use crate::types::MakerOrderEvent; + use nostr::{EventId, Filter}; + use std::collections::{BTreeMap, BTreeSet}; + + pub async fn handle(client: &RelayClient, event_id: EventId) -> crate::error::Result> { + let events = client + .req_and_wait(Filter { + ids: Some(BTreeSet::from([event_id])), + authors: None, + kinds: None, + search: None, + since: None, + until: None, + limit: None, + generic_tags: BTreeMap::default(), + }) + .await?; + let events = filter_maker_order_events(&events); + let events = sort_maker_order_events_by_time(events); + Ok(events) + } +} diff --git a/crates/dex-nostr-relay/src/handlers/list_orders.rs b/crates/dex-nostr-relay/src/handlers/list_orders.rs index ed1baa6..8ecc03a 100644 --- a/crates/dex-nostr-relay/src/handlers/list_orders.rs +++ b/crates/dex-nostr-relay/src/handlers/list_orders.rs @@ -1,27 +1,18 @@ -use crate::types::{CustomKind, MakerOrderKind}; - +use crate::handlers::common::filter_maker_order_events; use crate::relay_client::RelayClient; - -use std::collections::{BTreeMap, BTreeSet}; - -use nostr::{Filter, Timestamp}; +use crate::relay_processor::ListOrdersEventFilter; +use crate::types::{MakerOrderEvent, MakerOrderSummary}; +use nostr::Timestamp; use nostr_sdk::prelude::Events; -pub async fn handle(client: &RelayClient) -> crate::error::Result { - let events = client - .req_and_wait(Filter { - ids: None, - authors: None, - kinds: Some(BTreeSet::from([MakerOrderKind::get_kind()])), - search: None, - since: None, - until: None, - limit: None, - generic_tags: BTreeMap::default(), - }) - .await?; - +pub async fn handle( + client: &RelayClient, + filter: ListOrdersEventFilter, +) -> crate::error::Result> { + let events = client.req_and_wait(filter.to_filter()).await?; let events = filter_expired_events(events); + let events = filter_maker_order_events(&events); + let events = events.iter().map(MakerOrderEvent::summary).collect(); Ok(events) } @@ -31,7 +22,7 @@ fn filter_expired_events(events_to_filter: Events) -> Events { events_to_filter .into_iter() .filter(|x| match x.tags.expiration() { - None => false, + None => true, Some(t) => t.as_u64() > time_now.as_u64(), }) .collect() diff --git a/crates/dex-nostr-relay/src/handlers/mod.rs b/crates/dex-nostr-relay/src/handlers/mod.rs index 62df426..ad6f802 100644 --- a/crates/dex-nostr-relay/src/handlers/mod.rs +++ b/crates/dex-nostr-relay/src/handlers/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod common; pub(crate) mod get_events; pub(crate) mod list_orders; pub(crate) mod order_replies; diff --git a/crates/dex-nostr-relay/src/handlers/order_replies.rs b/crates/dex-nostr-relay/src/handlers/order_replies.rs index 5de0059..917c365 100644 --- a/crates/dex-nostr-relay/src/handlers/order_replies.rs +++ b/crates/dex-nostr-relay/src/handlers/order_replies.rs @@ -1,17 +1,17 @@ use crate::relay_client::RelayClient; -use crate::types::{CustomKind, TakerOrderKind}; +use crate::types::{CustomKind, OrderReplyEvent, TakerReplyOrderKind}; use std::collections::{BTreeMap, BTreeSet}; +use crate::handlers::common::{filter_order_reply_events, sort_order_replies_by_time}; use nostr::{EventId, Filter, SingleLetterTag}; -use nostr_sdk::prelude::Events; -pub async fn handle(client: &RelayClient, event_id: EventId) -> crate::error::Result { +pub async fn handle(client: &RelayClient, event_id: EventId) -> crate::error::Result> { let events = client .req_and_wait(Filter { ids: None, authors: None, - kinds: Some(BTreeSet::from([TakerOrderKind::get_kind()])), + kinds: Some(BTreeSet::from([TakerReplyOrderKind::get_kind()])), search: None, since: None, until: None, @@ -19,5 +19,8 @@ pub async fn handle(client: &RelayClient, event_id: EventId) -> crate::error::Re generic_tags: BTreeMap::from([(SingleLetterTag::from_char('e')?, BTreeSet::from([event_id.to_string()]))]), }) .await?; + let events = filter_order_reply_events(&events); + let events = sort_order_replies_by_time(events); + Ok(events) } diff --git a/crates/dex-nostr-relay/src/handlers/place_order.rs b/crates/dex-nostr-relay/src/handlers/place_order.rs index 68d50fb..5388486 100644 --- a/crates/dex-nostr-relay/src/handlers/place_order.rs +++ b/crates/dex-nostr-relay/src/handlers/place_order.rs @@ -1,29 +1,18 @@ use crate::relay_client::RelayClient; use crate::relay_processor::OrderPlaceEventTags; -use crate::types::{BLOCKSTREAM_MAKER_CONTENT, CustomKind, MAKER_EXPIRATION_TIME, MakerOrderKind}; +use crate::types::{BLOCKSTREAM_MAKER_CONTENT, CustomKind, MakerOrderEvent, MakerOrderKind}; +use nostr::{EventBuilder, EventId, Timestamp}; +use simplicity::elements::Txid; -use std::borrow::Cow; - -use nostr::{EventBuilder, EventId, Tag, TagKind, Timestamp}; - -pub async fn handle(client: &RelayClient, tags: OrderPlaceEventTags) -> crate::error::Result { +pub async fn handle(client: &RelayClient, tags: OrderPlaceEventTags, tx_id: Txid) -> crate::error::Result { let client_signer = client.get_signer().await?; let client_pubkey = client_signer.get_public_key().await?; let timestamp_now = Timestamp::now(); + let tags = MakerOrderEvent::form_tags(tags, tx_id, client_pubkey)?; let maker_order = EventBuilder::new(MakerOrderKind::get_kind(), BLOCKSTREAM_MAKER_CONTENT) - .tags([ - Tag::public_key(client_pubkey), - Tag::expiration(Timestamp::from(timestamp_now.as_u64() + MAKER_EXPIRATION_TIME)), - Tag::custom( - TagKind::Custom(Cow::from("compiler")), - [tags.compiler_name, tags.compiler_build_hash], - ), - Tag::custom(TagKind::Custom(Cow::from("asset_to_buy")), [tags.asset_to_buy]), - Tag::custom(TagKind::Custom(Cow::from("asset_to_sell")), [tags.asset_to_sell]), - Tag::custom(TagKind::Custom(Cow::from("price")), [tags.price.to_string()]), - ]) + .tags(tags) .custom_created_at(timestamp_now); let text_note = maker_order.build(client_pubkey); diff --git a/crates/dex-nostr-relay/src/handlers/reply_order.rs b/crates/dex-nostr-relay/src/handlers/reply_order.rs index dc5a532..d887f87 100644 --- a/crates/dex-nostr-relay/src/handlers/reply_order.rs +++ b/crates/dex-nostr-relay/src/handlers/reply_order.rs @@ -1,32 +1,25 @@ use crate::relay_client::RelayClient; -use crate::relay_processor::OrderReplyEventTags; -use crate::types::{BLOCKSTREAM_TAKER_CONTENT, CustomKind, TakerOrderKind}; +use crate::types::ReplyOption; -use std::borrow::Cow; - -use nostr::{EventBuilder, EventId, NostrSigner, PublicKey, Tag, TagKind, Timestamp}; +use nostr::{EventBuilder, EventId, Timestamp}; pub async fn handle( client: &RelayClient, - maker_event_id: EventId, - maker_pubkey: PublicKey, - tags: OrderReplyEventTags, + source_event_id: EventId, + reply_option: ReplyOption, ) -> crate::error::Result { let client_signer = client.get_signer().await?; let client_pubkey = client_signer.get_public_key().await?; - let timestamp_now = Timestamp::now(); - let taker_response = EventBuilder::new(TakerOrderKind::get_kind(), BLOCKSTREAM_TAKER_CONTENT) - .tags([ - Tag::public_key(client_pubkey), - Tag::event(maker_event_id), - Tag::custom(TagKind::Custom(Cow::from("maker_pubkey")), [maker_pubkey]), - Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tags.tx_id]), - ]) + // Build tags based on reply option variant + let tags = reply_option.form_tags(source_event_id, client_pubkey); + + let reply_event_builder = EventBuilder::new(reply_option.get_kind(), reply_option.get_content()) + .tags(tags) .custom_created_at(timestamp_now); - let reply_event = taker_response.build(client_pubkey); + let reply_event = reply_event_builder.build(client_pubkey); let reply_event = client_signer.sign_event(reply_event).await?; let event_id = client.publish_event(&reply_event).await?; diff --git a/crates/dex-nostr-relay/src/lib.rs b/crates/dex-nostr-relay/src/lib.rs index cee9880..216b4b5 100644 --- a/crates/dex-nostr-relay/src/lib.rs +++ b/crates/dex-nostr-relay/src/lib.rs @@ -1,3 +1,5 @@ +#![warn(clippy::all, clippy::pedantic)] + pub mod error; pub mod handlers; pub mod relay_client; diff --git a/crates/dex-nostr-relay/src/relay_client.rs b/crates/dex-nostr-relay/src/relay_client.rs index db8bc52..63cc7e4 100644 --- a/crates/dex-nostr-relay/src/relay_client.rs +++ b/crates/dex-nostr-relay/src/relay_client.rs @@ -12,7 +12,7 @@ use nostr_sdk::{Client, Relay, SubscribeAutoCloseOptions}; use tracing::instrument; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct RelayClient { client: Client, timeout: Duration, @@ -24,6 +24,12 @@ pub struct ClientConfig { } impl RelayClient { + /// Connect to one or more Nostr relays and return a configured `RelayClient`. + /// + /// # Errors + /// + /// Returns an error if a relay URL cannot be converted or if adding a relay + /// to the underlying `nostr_sdk::Client` fails. #[instrument(skip_all, level = "debug", err)] pub async fn connect( relay_urls: impl IntoIterator, @@ -45,7 +51,7 @@ impl RelayClient { let url = url .try_into_url() .map_err(|err| NostrRelayError::FailedToConvertRelayUrl { - err_msg: format!("{:?}", err), + err_msg: format!("{err:?}"), })?; client.add_relay(url).await?; @@ -59,6 +65,11 @@ impl RelayClient { }) } + /// Request events from connected relays using the provided filter. + /// + /// # Errors + /// + /// Returns an error if fetching events from the underlying client fails. #[instrument(skip_all, level = "debug", ret)] pub async fn req_and_wait(&self, filter: Filter) -> crate::error::Result { tracing::debug!(filter = ?filter, "Requesting events with filter"); @@ -66,6 +77,12 @@ impl RelayClient { Ok(self.client.fetch_combined_events(filter, self.timeout).await?) } + /// Return the configured signer for this relay client. + /// + /// # Errors + /// + /// Returns `NostrRelayError::MissingSigner` if no signer is configured, + /// or an error if obtaining the signer from the underlying client fails. #[instrument(skip_all, level = "debug", ret)] pub async fn get_signer(&self) -> crate::error::Result> { if !self.client.has_signer().await { @@ -80,6 +97,12 @@ impl RelayClient { self.client.relays().await } + /// Publish a signed event to connected relays and return its `EventId`. + /// + /// # Errors + /// + /// Returns `NostrRelayError::MissingSigner` if no signer is configured, + /// or an error if sending the event to the underlying client fails. #[instrument(skip_all, level = "debug", ret)] pub async fn publish_event(&self, event: &Event) -> crate::error::Result { if !self.client.has_signer().await { @@ -92,6 +115,11 @@ impl RelayClient { Ok(event_id) } + /// Subscribe to events matching the given filter. + /// + /// # Errors + /// + /// Returns an error if subscribing via the underlying client fails. #[instrument(skip(self), level = "debug")] pub async fn subscribe( &self, @@ -106,13 +134,23 @@ impl RelayClient { self.client.unsubscribe(subscription_id).await; } + /// Disconnect from all configured relays. + /// + /// # Errors + /// + /// Currently does not report errors and always returns `Ok(())`. #[instrument(skip_all, level = "debug", ret)] pub async fn disconnect(&self) -> crate::error::Result<()> { self.client.disconnect().await; - Ok(()) } + /// Handle the output from a relay operation. + /// + /// # Errors + /// + /// Currently does not report errors and always returns `Ok(output.val)`. + /// This may change if error handling for relay outputs is extended. /// TODO: handle error #[instrument(level = "debug")] fn handle_relay_output(output: Output) -> crate::error::Result { diff --git a/crates/dex-nostr-relay/src/relay_processor.rs b/crates/dex-nostr-relay/src/relay_processor.rs index cf9f554..caffd54 100644 --- a/crates/dex-nostr-relay/src/relay_processor.rs +++ b/crates/dex-nostr-relay/src/relay_processor.rs @@ -1,31 +1,72 @@ use crate::handlers; use crate::relay_client::{ClientConfig, RelayClient}; - +use crate::types::{CustomKind, MakerOrderEvent, MakerOrderSummary, OrderReplyEvent, ReplyOption}; +use contracts::DCDArguments; use nostr::prelude::IntoNostrSigner; use nostr::{EventId, PublicKey, TryIntoUrl}; - use nostr_sdk::prelude::Events; +use simplicityhl::elements::{AssetId, Txid}; + +use nostr::{Filter, Timestamp}; +use std::collections::{BTreeMap, BTreeSet}; +#[derive(Debug, Clone)] pub struct RelayProcessor { relay_client: RelayClient, } #[derive(Debug, Default, Clone)] pub struct OrderPlaceEventTags { - pub asset_to_sell: String, - pub asset_to_buy: String, - pub price: u64, - pub expiry: u64, - pub compiler_name: String, - pub compiler_build_hash: String, + pub dcd_arguments: DCDArguments, + pub dcd_taproot_pubkey_gen: String, + pub filler_asset_id: AssetId, + pub grantor_collateral_asset_id: AssetId, + pub grantor_settlement_asset_id: AssetId, + pub settlement_asset_id: AssetId, + pub collateral_asset_id: AssetId, } #[derive(Debug, Default, Clone)] -pub struct OrderReplyEventTags { - pub tx_id: String, +pub struct ListOrdersEventFilter { + pub authors: Option>, + pub since: Option, + pub until: Option, + pub limit: Option, +} + +impl ListOrdersEventFilter { + #[must_use] + pub fn to_filter(&self) -> Filter { + let authors_set = if let Some(list) = &self.authors { + let mut set = BTreeSet::new(); + for pk in list { + set.insert(*pk); + } + if set.is_empty() { None } else { Some(set) } + } else { + None + }; + + Filter { + ids: None, + authors: authors_set, + kinds: Some(BTreeSet::from([crate::types::MakerOrderKind::get_kind()])), + search: None, + since: self.since, + until: self.until, + limit: self.limit, + generic_tags: BTreeMap::default(), + } + } } impl RelayProcessor { + /// Create a [`RelayProcessor`] from relay URLs, optional keys, and client configuration. + /// + /// # Errors + /// + /// Returns an error if connecting to any of the provided relays or + /// configuring the underlying [`RelayClient`] fails. pub async fn try_from_config( relay_urls: impl IntoIterator, keys: Option, @@ -36,28 +77,80 @@ impl RelayProcessor { }) } - pub async fn place_order(&self, tags: OrderPlaceEventTags) -> crate::error::Result { - handlers::place_order::handle(&self.relay_client, tags).await + /// Place a new maker order event on the relay network. + /// + /// # Errors + /// + /// Returns an error if constructing or publishing the order event fails, + /// or if the relay client encounters an error while sending the event. + pub async fn place_order(&self, tags: OrderPlaceEventTags, tx_id: Txid) -> crate::error::Result { + let event_id = handlers::place_order::handle(&self.relay_client, tags, tx_id).await?; + Ok(event_id) + } + + /// List maker orders matching the provided filter. + /// + /// # Errors + /// + /// Returns an error if querying relays for matching maker order events + /// fails or if parsing retrieved events into [`MakerOrderSummary`] fails. + pub async fn list_orders(&self, filter: ListOrdersEventFilter) -> crate::error::Result> { + let events = handlers::list_orders::handle(&self.relay_client, filter).await?; + Ok(events) } - pub async fn list_orders(&self) -> crate::error::Result { - handlers::list_orders::handle(&self.relay_client).await + /// Send a reply to an order event with the given reply option. + /// + /// # Errors + /// + /// Returns an error if building, signing, or publishing the reply event + /// fails, or if the relay client fails to send the event. + pub async fn reply_order(&self, event_source: EventId, reply_option: ReplyOption) -> crate::error::Result { + let event_id = handlers::reply_order::handle(&self.relay_client, event_source, reply_option).await?; + Ok(event_id) } - pub async fn reply_order( - &self, - maker_event_id: EventId, - maker_pubkey: PublicKey, - tags: OrderReplyEventTags, - ) -> crate::error::Result { - handlers::reply_order::handle(&self.relay_client, maker_event_id, maker_pubkey, tags).await + /// Fetch reply events for a given order event. + /// + /// # Errors + /// + /// Returns an error if querying relays for reply events fails or if + /// parsing the retrieved events into [`OrderReplyEvent`] fails. + pub async fn get_order_replies(&self, event_id: EventId) -> crate::error::Result> { + let events = handlers::order_replies::handle(&self.relay_client, event_id).await?; + Ok(events) } - pub async fn get_order_replies(&self, event_id: EventId) -> crate::error::Result { - handlers::order_replies::handle(&self.relay_client, event_id).await + /// Fetch a single maker order event by its event ID. + /// + /// # Errors + /// + /// Returns: + /// - `NostrRelayError::NoEventsFound` if no event with the given ID is found. + /// - `NostrRelayError::NotOnlyOneEventFound` if more than one matching event is found. + /// - Any error produced by querying relays for the event. + pub async fn get_order_by_id(&self, event_id: EventId) -> crate::error::Result { + let mut events = handlers::get_events::order::handle(&self.relay_client, event_id).await?; + if events.is_empty() { + return Err(crate::error::NostrRelayError::NoEventsFound(format!( + "event_id: {event_id}" + ))); + } else if events.len() > 1 { + return Err(crate::error::NostrRelayError::NotOnlyOneEventFound(format!( + "event_id: {event_id}" + ))); + } + Ok(events.remove(0)) } - pub async fn get_events_by_id(&self, event_id: EventId) -> crate::error::Result { - handlers::get_events::ids::handle(&self.relay_client, event_id).await + /// Fetch raw events with the given event ID. + /// + /// # Errors + /// + /// Returns an error if querying relays for the event fails or if the + /// underlying relay client encounters an error while fetching events. + pub async fn get_event_by_id(&self, event_id: EventId) -> crate::error::Result { + let events = handlers::get_events::ids::handle(&self.relay_client, event_id).await?; + Ok(events) } } diff --git a/crates/dex-nostr-relay/src/types.rs b/crates/dex-nostr-relay/src/types.rs index bff1d57..132066d 100644 --- a/crates/dex-nostr-relay/src/types.rs +++ b/crates/dex-nostr-relay/src/types.rs @@ -1,30 +1,702 @@ -use nostr::Kind; +use crate::handlers::common::timestamp_to_chrono_utc; +use crate::relay_processor::OrderPlaceEventTags; +use chrono::TimeZone; +use contracts::DCDArguments; +use nostr::{Event, EventId, Kind, PublicKey, Tag, TagKind, Tags}; +use simplicity::elements::AssetId; +use simplicity::elements::OutPoint; +use simplicityhl::elements::Txid; +use std::borrow::Cow; +use std::fmt; +use std::str::FromStr; pub trait CustomKind { const ORDER_KIND_NUMBER: u16; + #[must_use] fn get_kind() -> Kind { Kind::from(Self::ORDER_KIND_NUMBER) } + #[must_use] fn get_u16() -> u16 { Self::ORDER_KIND_NUMBER } } -pub const BLOCKSTREAM_MAKER_CONTENT: &str = "Liquid order [Maker]"; -pub const BLOCKSTREAM_TAKER_CONTENT: &str = "Liquid order [Taker]"; +pub const POW_DIFFICULTY: u8 = 1; +pub const BLOCKSTREAM_MAKER_CONTENT: &str = "Liquid order [Maker]!"; +pub const BLOCKSTREAM_TAKER_REPLY_CONTENT: &str = "Liquid reply [Taker]!"; +pub const BLOCKSTREAM_MAKER_REPLY_CONTENT: &str = "Liquid reply [Maker]!"; +pub const BLOCKSTREAM_MERGE2_REPLY_CONTENT: &str = "Liquid merge [Merge2]!"; +pub const BLOCKSTREAM_MERGE3_REPLY_CONTENT: &str = "Liquid merge [Merge3]!"; +pub const BLOCKSTREAM_MERGE4_REPLY_CONTENT: &str = "Liquid merge [Merge4]!"; -// TODO: move to the config -pub const MAKER_EXPIRATION_TIME: u64 = 60; +/// `MAKER_EXPIRATION_TIME` = 31 days +/// TODO: move to the config +pub const MAKER_EXPIRATION_TIME: u64 = 2_678_400; +pub const MAKER_DCD_ARG_TAG: &str = "dcd_arguments_(hex&bincode)"; +pub const MAKER_DCD_TAPROOT_TAG: &str = "dcd_taproot_pubkey_gen"; +pub const MAKER_FILLER_ASSET_ID_TAG: &str = "filler_asset_id"; +pub const MAKER_GRANTOR_COLLATERAL_ASSET_ID_TAG: &str = "grantor_collateral_asset_id"; +pub const MAKER_GRANTOR_SETTLEMENT_ASSET_ID_TAG: &str = "grantor_settlement_asset_id"; +pub const MAKER_SETTLEMENT_ASSET_ID_TAG: &str = "settlement_asset_id"; +pub const MAKER_COLLATERAL_ASSET_ID_TAG: &str = "collateral_asset_id"; +pub const MAKER_FUND_TX_ID_TAG: &str = "maker_fund_tx_id"; pub struct MakerOrderKind; -pub struct TakerOrderKind; +pub struct TakerReplyOrderKind; +pub struct MakerReplyOrderKind; +pub struct MergeReplyOrderKind; impl CustomKind for MakerOrderKind { const ORDER_KIND_NUMBER: u16 = 9901; } -impl CustomKind for TakerOrderKind { +impl CustomKind for TakerReplyOrderKind { const ORDER_KIND_NUMBER: u16 = 9902; } + +impl CustomKind for MakerReplyOrderKind { + const ORDER_KIND_NUMBER: u16 = 9903; +} + +impl CustomKind for MergeReplyOrderKind { + const ORDER_KIND_NUMBER: u16 = 9904; +} + +#[derive(Debug)] +pub struct MakerOrderEvent { + pub event_id: EventId, + pub time: chrono::DateTime, + pub dcd_arguments: DCDArguments, + pub dcd_taproot_pubkey_gen: String, + pub filler_asset_id: AssetId, + pub grantor_collateral_asset_id: AssetId, + pub grantor_settlement_asset_id: AssetId, + pub settlement_asset_id: AssetId, + pub collateral_asset_id: AssetId, + pub maker_fund_tx_id: Txid, +} + +#[derive(Debug, Clone)] +pub enum ReplyOption { + TakerFund { + tx_id: Txid, + }, + MakerTerminationCollateral { + tx_id: Txid, + }, + MakerTerminationSettlement { + tx_id: Txid, + }, + MakerSettlement { + tx_id: Txid, + }, + TakerTerminationEarly { + tx_id: Txid, + }, + TakerSettlement { + tx_id: Txid, + }, + Merge2 { + tx_id: Txid, + token_utxo_1: OutPoint, + token_utxo_2: OutPoint, + }, + Merge3 { + tx_id: Txid, + token_utxo_1: OutPoint, + token_utxo_2: OutPoint, + token_utxo_3: OutPoint, + }, + Merge4 { + tx_id: Txid, + token_utxo_1: OutPoint, + token_utxo_2: OutPoint, + token_utxo_3: OutPoint, + token_utxo_4: OutPoint, + }, +} + +#[derive(Debug)] +pub struct OrderReplyEvent { + pub event_id: EventId, + pub event_kind: Kind, + pub time: chrono::DateTime, + pub reply_option: ReplyOption, +} + +// New: brief display-ready summary of a maker order. +#[derive(Debug, Clone)] +pub struct MakerOrderSummary { + pub taproot_key_gen: String, + pub strike_price: u64, + pub principal: String, + pub incentive_basis_points: u64, + // changed: use Option> for taker funding window so zero means "missing" + pub taker_fund_start_time: Option>, + pub taker_fund_end_time: Option>, + pub settlement_height: u32, + pub oracle_short: String, + pub collateral_asset_id: String, + pub settlement_asset_id: String, + pub interest_collateral: String, + pub total_collateral: String, + pub interest_asset: String, + pub total_asset: String, + // new: event time for the order summary + pub time: chrono::DateTime, + // new: maker funding transaction id (short display) + pub maker_fund_tx_id: String, + // new: originating event id + pub event_id: EventId, +} + +impl fmt::Display for MakerOrderSummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let oracle_full = &self.oracle_short; + let oracle_short = if oracle_full.is_empty() { + "n/a" + } else if oracle_full.len() > 8 { + &oracle_full[..8] + } else { + oracle_full.as_str() + }; + + let taker_range = match (self.taker_fund_start_time.as_ref(), self.taker_fund_end_time.as_ref()) { + (None, None) => "n/a".to_string(), + (Some(s), Some(e)) => format!("({})..({})", s.to_rfc3339(), e.to_rfc3339()), + (Some(s), None) => format!("({})..n/a", s.to_rfc3339()), + (None, Some(e)) => format!("n/a..({})", e.to_rfc3339()), + }; + + write!(f, "[Maker Order - Summary]",)?; + writeln!(f, "\t\t event_id:\t{}", self.event_id)?; + writeln!(f, "\t\t time:\t{}", self.time)?; + writeln!( + f, + "\t\t taker_fund_[start..end]:\t({:?})..({:?})", + self.taker_fund_start_time, self.taker_fund_end_time + )?; + writeln!(f, "\t\t taproot_pubkey_gen:\t{}", self.taproot_key_gen)?; + writeln!(f, "\torder_params:")?; + writeln!(f, "\t\t strike_price:\t{}", self.strike_price)?; + writeln!(f, "\t\t principal:\t{}", self.principal)?; + writeln!(f, "\t\t incentive_bps:\t{}", self.incentive_basis_points)?; + writeln!(f, "\t\t settlement_height:\t{}", self.settlement_height)?; + writeln!(f, "\t\t taker_funding:\t{taker_range}")?; + writeln!(f, "\t\t settlement_height:\t{}", self.settlement_height)?; + writeln!(f, "\t\t oracle_pubkey:\t{oracle_short}")?; + writeln!(f, "\t assets:")?; + writeln!(f, "\t\t interest_collateral:\t{}", self.interest_collateral)?; + writeln!(f, "\t\t total_collateral:\t{}", self.total_collateral)?; + writeln!(f, "\t\t interest_asset:\t{}", self.interest_asset)?; + writeln!(f, "\t\t total_asset:\t{}", self.total_asset)?; + writeln!(f, "\t\t collateral_asset_id:\t{}", self.collateral_asset_id)?; + writeln!(f, "\t\t settlement_asset_id:\t{}", self.settlement_asset_id)?; + + writeln!(f, "\t maker_fund_tx_id:\t{}", self.maker_fund_tx_id)?; + + Ok(()) + } +} + +impl fmt::Display for MakerOrderEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let event_full = self.event_id.to_string(); + let event_short = if event_full.len() > 8 { + &event_full[..8] + } else { + &event_full[..] + }; + + let time_str = self.time.to_rfc3339(); + + let oracle_full = &self.dcd_arguments.oracle_public_key; + let oracle_display = if oracle_full.is_empty() { + "n/a".to_string() + } else { + oracle_full.clone() + }; + + let taker_start = { + let ts = self.dcd_arguments.taker_funding_start_time; + if ts == 0 { + None + } else { + chrono::Utc.timestamp_opt(i64::from(ts), 0).single() + } + }; + let taker_end = { + let ts = self.dcd_arguments.taker_funding_end_time; + if ts == 0 { + None + } else { + chrono::Utc.timestamp_opt(i64::from(ts), 0).single() + } + }; + let taker_range = match (taker_start, taker_end) { + (None, None) => "n/a".to_string(), + (Some(s), Some(e)) => format!("{}..{}", s.to_rfc3339(), e.to_rfc3339()), + (Some(s), None) => format!("{}..n/a", s.to_rfc3339()), + (None, Some(e)) => format!("n/a..{}", e.to_rfc3339()), + }; + + // ratio-derived amounts (use "n/a" for zero) + let r = &self.dcd_arguments.ratio_args; + let principal = if r.principal_collateral_amount > 0 { + r.principal_collateral_amount.to_string() + } else { + "n/a".to_string() + }; + let interest_collateral = if r.interest_collateral_amount > 0 { + r.interest_collateral_amount.to_string() + } else { + "n/a".to_string() + }; + let total_collateral = if r.total_collateral_amount > 0 { + r.total_collateral_amount.to_string() + } else { + "n/a".to_string() + }; + let interest_asset = if r.interest_asset_amount > 0 { + r.interest_asset_amount.to_string() + } else { + "n/a".to_string() + }; + let total_asset = if r.total_asset_amount > 0 { + r.total_asset_amount.to_string() + } else { + "n/a".to_string() + }; + + let filler = format!("{}", self.filler_asset_id); + let grantor_collateral = format!("{}", self.grantor_collateral_asset_id); + let grantor_settlement = format!("{}", self.grantor_settlement_asset_id); + let settlement = format!("{}", self.settlement_asset_id); + let collateral = format!("{}", self.collateral_asset_id); + let maker_tx = self.maker_fund_tx_id.to_string(); + + writeln!(f, "[Maker Order - Detail]\n\tevent_id={event_short}\ttime={time_str}",)?; + writeln!(f, "\t dcd_taproot_pubkey_gen:\t{}", self.dcd_taproot_pubkey_gen)?; + writeln!(f, "\t maker_fund_tx_id:\t{maker_tx}",)?; + writeln!(f, "\tdcd_arguments:")?; + writeln!(f, "\t\t strike_price:\t{}", self.dcd_arguments.strike_price)?; + writeln!(f, "\t\t incentive_bps:\t{}", self.dcd_arguments.incentive_basis_points)?; + writeln!(f, "\t\t taker_funding:\t{taker_range}",)?; + writeln!(f, "\t\t settlement_height:\t{}", self.dcd_arguments.settlement_height)?; + writeln!(f, "\t\t oracle_pubkey:\t{oracle_display}",)?; + writeln!(f, "\t\t ratio.principal_collateral:\t{principal}",)?; + writeln!(f, "\t\t ratio.interest_collateral:\t{interest_collateral}",)?; + writeln!(f, "\t\t ratio.total_collateral:\t{total_collateral}",)?; + writeln!(f, "\t\t ratio.interest_asset:\t{interest_asset}",)?; + writeln!(f, "\t\t ratio.total_asset:\t{total_asset}",)?; + + writeln!(f, "\tassets:")?; + writeln!(f, "\t\t filler_asset_id:\t{filler}")?; + writeln!(f, "\t\t grantor_collateral_asset_id:\t{grantor_collateral}")?; + writeln!(f, "\t\t grantor_settlement_asset_id:\t{grantor_settlement}")?; + writeln!(f, "\t\t settlement_asset_id:\t{settlement}")?; + writeln!(f, "\t\t collateral_asset_id:\t{collateral}")?; + + writeln!( + f, + "\n\tfull_dcd_arguments_debug:\n\t{}", + format_args!("{:#?}", self.dcd_arguments) + )?; + + Ok(()) + } +} + +impl MakerOrderEvent { + #[must_use] + pub fn summary(&self) -> MakerOrderSummary { + let oracle_full = &self.dcd_arguments.oracle_public_key; + let oracle_short = if oracle_full.is_empty() { + "n/a".to_string() + } else if oracle_full.len() > 8 { + oracle_full[..8].to_string() + } else { + oracle_full.clone() + }; + + let principal = match &self.dcd_arguments.ratio_args { + r if r.principal_collateral_amount > 0 => r.principal_collateral_amount.to_string(), + _ => "n/a".to_string(), + }; + + let (interest_collateral, total_collateral, interest_asset, total_asset) = { + let r = &self.dcd_arguments.ratio_args; + ( + if r.interest_collateral_amount > 0 { + r.interest_collateral_amount.to_string() + } else { + "n/a".to_string() + }, + if r.total_collateral_amount > 0 { + r.total_collateral_amount.to_string() + } else { + "n/a".to_string() + }, + if r.interest_asset_amount > 0 { + r.interest_asset_amount.to_string() + } else { + "n/a".to_string() + }, + if r.total_asset_amount > 0 { + r.total_asset_amount.to_string() + } else { + "n/a".to_string() + }, + ) + }; + + let collateral_id = format!("{}", self.collateral_asset_id); + let settlement_id = format!("{}", self.settlement_asset_id); + + MakerOrderSummary { + taproot_key_gen: self.dcd_taproot_pubkey_gen.clone(), + strike_price: self.dcd_arguments.strike_price, + principal, + incentive_basis_points: self.dcd_arguments.incentive_basis_points, + taker_fund_start_time: { + let ts = self.dcd_arguments.taker_funding_start_time; + if ts == 0 { + None + } else { + chrono::Utc.timestamp_opt(i64::from(ts), 0).single() + } + }, + taker_fund_end_time: { + let ts = self.dcd_arguments.taker_funding_end_time; + if ts == 0 { + None + } else { + chrono::Utc.timestamp_opt(i64::from(ts), 0).single() + } + }, + settlement_height: self.dcd_arguments.settlement_height, + oracle_short, + collateral_asset_id: collateral_id, + settlement_asset_id: settlement_id, + interest_collateral, + total_collateral, + interest_asset, + total_asset, + time: self.time, + maker_fund_tx_id: self.maker_fund_tx_id.to_string(), + event_id: self.event_id, + } + } + + pub fn parse_event(event: &Event) -> Option { + event.verify().ok()?; + if event.kind != MakerOrderKind::get_kind() { + return None; + } + let time = timestamp_to_chrono_utc(event.created_at)?; + let dcd_arguments = { + let bytes = hex::decode(event.tags.get(0)?.content()?).ok()?; + let decoded: DCDArguments = bincode::decode_from_slice(&bytes, bincode::config::standard()).ok()?.0; + decoded + }; + let dcd_taproot_pubkey_gen = event.tags.get(1)?.content()?.to_string(); + let filler_asset_id = AssetId::from_str(event.tags.get(2)?.content()?).ok()?; + let grantor_collateral_asset_id = AssetId::from_str(event.tags.get(3)?.content()?).ok()?; + let grantor_settlement_asset_id = AssetId::from_str(event.tags.get(4)?.content()?).ok()?; + let settlement_asset_id = AssetId::from_str(event.tags.get(5)?.content()?).ok()?; + let collateral_asset_id = AssetId::from_str(event.tags.get(6)?.content()?).ok()?; + let maker_fund_tx_id = Txid::from_str(event.tags.get(7)?.content()?).ok()?; + + Some(MakerOrderEvent { + event_id: event.id, + time, + dcd_arguments, + dcd_taproot_pubkey_gen, + filler_asset_id, + grantor_collateral_asset_id, + grantor_settlement_asset_id, + settlement_asset_id, + collateral_asset_id, + maker_fund_tx_id, + }) + } + + /// Form a list of Nostr tags representing a maker order event. + /// + /// # Errors + /// + /// Returns `Err(crate::error::NostrRelayError::BincodeEncoding)` if serialization of + /// `DCDArguments` via `bincode` fails. The function returns a `crate::error::Result>` + /// to propagate that error to the caller. + pub fn form_tags( + tags: OrderPlaceEventTags, + tx_id: Txid, + client_pubkey: PublicKey, + ) -> crate::error::Result> { + let dcd_arguments = { + let x = bincode::encode_to_vec(&tags.dcd_arguments, bincode::config::standard()).map_err(|err| { + crate::error::NostrRelayError::BincodeEncoding { + err, + struct_to_encode: format!("DCDArgs: {:#?}", tags.dcd_arguments), + } + })?; + nostr::prelude::hex::encode(x) + }; + Ok(vec![ + Tag::public_key(client_pubkey), + // Tag::expiration(Timestamp::from(timestamp_now.as_u64() + MAKER_EXPIRATION_TIME)), + Tag::custom(TagKind::Custom(Cow::from(MAKER_DCD_ARG_TAG)), [dcd_arguments]), + Tag::custom( + TagKind::Custom(Cow::from(MAKER_DCD_TAPROOT_TAG)), + [tags.dcd_taproot_pubkey_gen], + ), + Tag::custom( + TagKind::Custom(Cow::from(MAKER_FILLER_ASSET_ID_TAG)), + [tags.filler_asset_id.to_string()], + ), + Tag::custom( + TagKind::Custom(Cow::from(MAKER_GRANTOR_COLLATERAL_ASSET_ID_TAG)), + [tags.grantor_collateral_asset_id.to_string()], + ), + Tag::custom( + TagKind::Custom(Cow::from(MAKER_GRANTOR_SETTLEMENT_ASSET_ID_TAG)), + [tags.grantor_settlement_asset_id.to_string()], + ), + Tag::custom( + TagKind::Custom(Cow::from(MAKER_SETTLEMENT_ASSET_ID_TAG)), + [tags.settlement_asset_id.to_string()], + ), + Tag::custom( + TagKind::Custom(Cow::from(MAKER_COLLATERAL_ASSET_ID_TAG)), + [tags.collateral_asset_id.to_string()], + ), + Tag::custom(TagKind::Custom(Cow::from(MAKER_FUND_TX_ID_TAG)), [tx_id.to_string()]), + ]) + } +} + +impl OrderReplyEvent { + pub fn parse_event(event: &Event) -> Option { + tracing::debug!("filtering event: {:?}", event); + event.verify().ok()?; + let time = timestamp_to_chrono_utc(event.created_at)?; + Some(OrderReplyEvent { + event_id: event.id, + event_kind: event.kind, + time, + reply_option: ReplyOption::parse_tags(&event.tags)?, + }) + } +} + +impl ReplyOption { + pub fn parse_tags(tags: &Tags) -> Option { + // Extract tx_id from custom tag + let tx_id = tags + .iter() + .find(|tag| matches!(tag.kind(), TagKind::Custom(s) if s.as_ref() == "tx_id")) + .and_then(|tag| tag.content()) + .and_then(|s| Txid::from_str(s).ok())?; + + // Extract reply_type from custom tag + let reply_type = tags + .iter() + .find(|tag| matches!(tag.kind(), TagKind::Custom(s) if s.as_ref() == "reply_type")) + .and_then(|tag| tag.content())?; + + // Helper to get OutPoint from a custom tag with given key + let get_outpoint = |key: &str| -> Option { + let s = tags + .iter() + .find(|tag| matches!(tag.kind(), TagKind::Custom(k) if k.as_ref() == key)) + .and_then(|tag| tag.content())?; + OutPoint::from_str(s).ok() + }; + + // Match reply_type to construct the appropriate variant + match reply_type { + "taker_fund" => Some(ReplyOption::TakerFund { tx_id }), + "maker_termination_collateral" => Some(ReplyOption::MakerTerminationCollateral { tx_id }), + "maker_termination_settlement" => Some(ReplyOption::MakerTerminationSettlement { tx_id }), + "maker_settlement" => Some(ReplyOption::MakerSettlement { tx_id }), + "taker_termination_early" => Some(ReplyOption::TakerTerminationEarly { tx_id }), + "taker_settlement" => Some(ReplyOption::TakerSettlement { tx_id }), + "tokens_merge2" => { + let token_utxo_1 = get_outpoint("token_utxo_1")?; + let token_utxo_2 = get_outpoint("token_utxo_2")?; + Some(ReplyOption::Merge2 { + tx_id, + token_utxo_1, + token_utxo_2, + }) + } + "tokens_merge3" => { + let token_utxo_1 = get_outpoint("token_utxo_1")?; + let token_utxo_2 = get_outpoint("token_utxo_2")?; + let token_utxo_3 = get_outpoint("token_utxo_3")?; + Some(ReplyOption::Merge3 { + tx_id, + token_utxo_1, + token_utxo_2, + token_utxo_3, + }) + } + "tokens_merge4" => { + let token_utxo_1 = get_outpoint("token_utxo_1")?; + let token_utxo_2 = get_outpoint("token_utxo_2")?; + let token_utxo_3 = get_outpoint("token_utxo_3")?; + let token_utxo_4 = get_outpoint("token_utxo_4")?; + Some(ReplyOption::Merge4 { + tx_id, + token_utxo_1, + token_utxo_2, + token_utxo_3, + token_utxo_4, + }) + } + _ => None, + } + } + + #[must_use] + pub fn get_kind(&self) -> Kind { + match self { + ReplyOption::TakerFund { .. } + | ReplyOption::TakerTerminationEarly { .. } + | ReplyOption::TakerSettlement { .. } => TakerReplyOrderKind::get_kind(), + ReplyOption::MakerTerminationCollateral { .. } + | ReplyOption::MakerTerminationSettlement { .. } + | ReplyOption::MakerSettlement { .. } => MakerReplyOrderKind::get_kind(), + ReplyOption::Merge2 { .. } | ReplyOption::Merge3 { .. } | ReplyOption::Merge4 { .. } => { + MergeReplyOrderKind::get_kind() + } + } + } + + #[must_use] + pub fn get_content(&self) -> String { + match self { + ReplyOption::TakerFund { .. } + | ReplyOption::TakerTerminationEarly { .. } + | ReplyOption::TakerSettlement { .. } => BLOCKSTREAM_TAKER_REPLY_CONTENT.to_string(), + ReplyOption::MakerTerminationCollateral { .. } + | ReplyOption::MakerTerminationSettlement { .. } + | ReplyOption::MakerSettlement { .. } => BLOCKSTREAM_MAKER_REPLY_CONTENT.to_string(), + ReplyOption::Merge2 { .. } => BLOCKSTREAM_MERGE2_REPLY_CONTENT.to_string(), + ReplyOption::Merge3 { .. } => BLOCKSTREAM_MERGE3_REPLY_CONTENT.to_string(), + ReplyOption::Merge4 { .. } => BLOCKSTREAM_MERGE4_REPLY_CONTENT.to_string(), + } + } + + #[allow(clippy::too_many_lines)] + #[must_use] + pub fn form_tags(&self, source_event_id: EventId, client_pubkey: PublicKey) -> Vec { + match self { + ReplyOption::TakerFund { tx_id } => { + vec![ + Tag::public_key(client_pubkey), + Tag::event(source_event_id), + Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tx_id.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("reply_type")), ["taker_fund"]), + ] + } + ReplyOption::MakerTerminationCollateral { tx_id } => { + vec![ + Tag::public_key(client_pubkey), + Tag::event(source_event_id), + Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tx_id.to_string()]), + Tag::custom( + TagKind::Custom(Cow::from("reply_type")), + ["maker_termination_collateral"], + ), + ] + } + ReplyOption::MakerTerminationSettlement { tx_id } => { + vec![ + Tag::public_key(client_pubkey), + Tag::event(source_event_id), + Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tx_id.to_string()]), + Tag::custom( + TagKind::Custom(Cow::from("reply_type")), + ["maker_termination_settlement"], + ), + ] + } + ReplyOption::MakerSettlement { tx_id } => { + vec![ + Tag::public_key(client_pubkey), + Tag::event(source_event_id), + Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tx_id.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("reply_type")), ["maker_settlement"]), + ] + } + ReplyOption::TakerTerminationEarly { tx_id } => { + vec![ + Tag::public_key(client_pubkey), + Tag::event(source_event_id), + Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tx_id.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("reply_type")), ["taker_termination_early"]), + ] + } + ReplyOption::TakerSettlement { tx_id } => { + vec![ + Tag::public_key(client_pubkey), + Tag::event(source_event_id), + Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tx_id.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("reply_type")), ["taker_settlement"]), + ] + } + ReplyOption::Merge2 { + tx_id, + token_utxo_1, + token_utxo_2, + } => { + vec![ + Tag::public_key(client_pubkey), + Tag::event(source_event_id), + Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tx_id.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("reply_type")), ["tokens_merge2"]), + Tag::custom(TagKind::Custom(Cow::from("token_utxo_1")), [token_utxo_1.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("token_utxo_2")), [token_utxo_2.to_string()]), + ] + } + ReplyOption::Merge3 { + tx_id, + token_utxo_1, + token_utxo_2, + token_utxo_3, + } => { + vec![ + Tag::public_key(client_pubkey), + Tag::event(source_event_id), + Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tx_id.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("reply_type")), ["tokens_merge3"]), + Tag::custom(TagKind::Custom(Cow::from("token_utxo_1")), [token_utxo_1.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("token_utxo_2")), [token_utxo_2.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("token_utxo_3")), [token_utxo_3.to_string()]), + ] + } + ReplyOption::Merge4 { + tx_id, + token_utxo_1, + token_utxo_2, + token_utxo_3, + token_utxo_4, + } => { + vec![ + Tag::public_key(client_pubkey), + Tag::event(source_event_id), + Tag::custom(TagKind::Custom(Cow::from("tx_id")), [tx_id.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("reply_type")), ["tokens_merge4"]), + Tag::custom(TagKind::Custom(Cow::from("token_utxo_1")), [token_utxo_1.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("token_utxo_2")), [token_utxo_2.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("token_utxo_3")), [token_utxo_3.to_string()]), + Tag::custom(TagKind::Custom(Cow::from("token_utxo_4")), [token_utxo_4.to_string()]), + ] + } + } + } +} diff --git a/crates/dex-nostr-relay/tests/test_order_placing.rs b/crates/dex-nostr-relay/tests/test_order_placing.rs index 3cb0735..a725a8e 100644 --- a/crates/dex-nostr-relay/tests/test_order_placing.rs +++ b/crates/dex-nostr-relay/tests/test_order_placing.rs @@ -2,21 +2,25 @@ mod utils; mod tests { use crate::utils::{DEFAULT_CLIENT_TIMEOUT, DEFAULT_RELAY_LIST, TEST_LOGGER}; - + use std::str::FromStr; use std::time::Duration; - use nostr::{EventId, Keys, ToBech32}; - use dex_nostr_relay::relay_client::ClientConfig; - use dex_nostr_relay::relay_processor::{OrderPlaceEventTags, OrderReplyEventTags, RelayProcessor}; - use dex_nostr_relay::types::{CustomKind, MakerOrderKind, TakerOrderKind}; + use dex_nostr_relay::relay_processor::{ListOrdersEventFilter, OrderPlaceEventTags, RelayProcessor}; + use dex_nostr_relay::types::{CustomKind, MakerOrderKind, ReplyOption}; + use nostr::{Keys, ToBech32}; + use simplicity::elements::OutPoint; + use simplicityhl::elements::Txid; use tracing::{info, instrument}; + #[ignore] #[instrument] #[tokio::test] async fn test_wss_metadata() -> anyhow::Result<()> { + let _ = dotenvy::dotenv(); let _guard = &*TEST_LOGGER; + let key_maker = Keys::generate(); info!( "=== Maker pubkey: {}, privatekey: {}", @@ -33,10 +37,13 @@ mod tests { .await?; let placed_order_event_id = relay_processor_maker - .place_order(OrderPlaceEventTags::default()) + .place_order( + OrderPlaceEventTags::default(), + Txid::from_str("87a4c9b2060ff698d9072d5f95b3dde01efe0994f95c3cd6dd7348cb3a4e4e40").unwrap(), + ) .await?; info!("=== placed order event id: {}", placed_order_event_id); - let order = relay_processor_maker.get_events_by_id(placed_order_event_id).await?; + let order = relay_processor_maker.get_event_by_id(placed_order_event_id).await?; info!("=== placed order: {:#?}", order); assert_eq!(order.len(), 1); assert_eq!(order.first().unwrap().kind, MakerOrderKind::get_kind()); @@ -55,25 +62,90 @@ mod tests { key_taker.public_key.to_bech32()?, key_taker.secret_key().to_bech32()? ); - let reply_event_id = relay_processor_taker - .reply_order( - placed_order_event_id, - key_maker.public_key, - OrderReplyEventTags::default(), - ) - .await?; - info!("=== order reply event id: {}", reply_event_id); + + // Common txid / outpoint used across reply options. + let tx_id = Txid::from_str("87a4c9b2060ff698d9072d5f95b3dde01efe0994f95c3cd6dd7348cb3a4e4e40")?; + let dummy_outpoint = OutPoint::from_str("87a4c9b2060ff698d9072d5f95b3dde01efe0994f95c3cd6dd7348cb3a4e4e40:0")?; + + // Send replies for all supported ReplyOption variants. + let reply_variants = vec![ + ReplyOption::TakerFund { tx_id }, + ReplyOption::MakerTerminationCollateral { tx_id }, + ReplyOption::MakerTerminationSettlement { tx_id }, + ReplyOption::MakerSettlement { tx_id }, + ReplyOption::TakerTerminationEarly { tx_id }, + ReplyOption::TakerSettlement { tx_id }, + ReplyOption::Merge2 { + tx_id, + token_utxo_1: dummy_outpoint, + token_utxo_2: dummy_outpoint, + }, + ReplyOption::Merge3 { + tx_id, + token_utxo_1: dummy_outpoint, + token_utxo_2: dummy_outpoint, + token_utxo_3: dummy_outpoint, + }, + ReplyOption::Merge4 { + tx_id, + token_utxo_1: dummy_outpoint, + token_utxo_2: dummy_outpoint, + token_utxo_3: dummy_outpoint, + token_utxo_4: dummy_outpoint, + }, + ]; + + for reply in &reply_variants { + let reply_event_id = relay_processor_taker + .reply_order(placed_order_event_id, reply.clone()) + .await?; + info!( + "=== order reply event id for {:?}: {}", + reply.get_content(), + reply_event_id + ); + } let order_replies = relay_processor_maker.get_order_replies(placed_order_event_id).await?; info!( "=== order replies, amount: {}, orders: {:#?}", order_replies.len(), + order_replies, + ); + + // Inline comparison instead of an explicit loop. + let all_kinds_match = order_replies + .iter() + .zip(reply_variants.iter()) + .enumerate() + .all(|(idx, (reply_event, expected_option))| { + if reply_event.event_kind != expected_option.get_kind() { + eprintln!( + "reply kind mismatch at index {idx}: \ + got {:?}, expected {:?}", + reply_event.event_kind, + expected_option.get_kind() + ); + return false; + } + true + }); + + assert!( + all_kinds_match, + "not all reply events have the expected kind; see stderr for details" ); - assert_eq!(order_replies.len(), 1); - assert_eq!(order_replies.first().unwrap().kind, TakerOrderKind::get_kind()); - let orders_listed = relay_processor_maker.list_orders().await?; + // Also confirm the placed order can be found via list_orders as before. + let orders_listed = relay_processor_maker + .list_orders(ListOrdersEventFilter { + authors: None, + since: None, + until: None, + limit: None, + }) + .await?; info!( "=== orders listed, amount: {}, orders: {:#?}", orders_listed.len(), @@ -82,8 +154,8 @@ mod tests { assert!( orders_listed .iter() - .map(|x| x.id) - .collect::>() + .map(|x| x.event_id) + .collect::>() .contains(&placed_order_event_id) ); diff --git a/crates/global-utils/Cargo.toml b/crates/global-utils/Cargo.toml index 187a262..6d32490 100644 --- a/crates/global-utils/Cargo.toml +++ b/crates/global-utils/Cargo.toml @@ -1,5 +1,9 @@ [package] name = "global-utils" +description = "Simplicity dex utils" +license = "MIT OR Apache-2.0" +publish = false + version.workspace = true edition.workspace = true rust-version.workspace = true diff --git a/crates/global-utils/src/lib.rs b/crates/global-utils/src/lib.rs index d991728..1f00764 100644 --- a/crates/global-utils/src/lib.rs +++ b/crates/global-utils/src/lib.rs @@ -1 +1,3 @@ +#![warn(clippy::all, clippy::pedantic)] + pub mod logger;