diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..3d78020 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,28 @@ +name: Bug Report +description: File a bug report +labels: ['bug'] +body: + - type: markdown + attributes: + value: Thanks for taking the time to fill out this bug report! + - type: input + id: version + attributes: + label: "Project version" + placeholder: "1.2.3" + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A brief description of what happened and what you expected to happen + validations: + required: true + - type: textarea + id: reproduction-steps + attributes: + label: "Minimal reproduction steps" + description: "The minimal steps needed to reproduce the bug" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..a7778e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,11 @@ +name: Feature request +description: Suggest a new feature +labels: ['feature'] +body: + - type: textarea + id: feature-description + attributes: + label: "Describe the feature" + description: "A description of what you would like to see in the project" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/other-issue.md b/.github/ISSUE_TEMPLATE/other-issue.md new file mode 100644 index 0000000..7115534 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other-issue.md @@ -0,0 +1,4 @@ +--- +name: Other issue +about: Other kind of issue +--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d34ade0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: Linting Checks and Tests + +on: + push: + branches: + - main + - dev + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-and-test: + name: Lint and Test (Ubuntu) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: clippy, rustfmt + + - name: Cache cargo registry, git and target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run Rust fmt + run: cargo fmt --all --check + + - name: Run clippy + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: Run tests + run: cargo test --workspace --all-features --locked --no-fail-fast diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..001b8ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Ignoring .js files, as typescript firstly compiles into it +/tests/*.js + +# Disable logs that have obtained during run +/logs +*.log +.env + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ +.vscode/ + +# frontend code +node_modules/ +dist/ + +# Mac OSX temporary files +.DS_Store +**/.DS_Store + +# Logs +logs/* \ No newline at end of file diff --git a/.simplicity-dex.example/keypair.txt b/.simplicity-dex.example/keypair.txt new file mode 100644 index 0000000..d130f25 --- /dev/null +++ b/.simplicity-dex.example/keypair.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.simplicity-dex.example/relays.txt b/.simplicity-dex.example/relays.txt new file mode 100644 index 0000000..6a6b8a4 --- /dev/null +++ b/.simplicity-dex.example/relays.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..10e1ee0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[workspace] +resolver = "3" +members = [ + "crates/*" +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +rust-version = "1.91.0" +authors = ["Blockstream"] +readme = "README.md" + + +[workspace.dependencies] +anyhow = { version = "1.0.100" } +clap = { version = "4.5.49", features = ["derive"] } +dirs = {version = "6.0.0"} +futures-util = { version = "0.3.31" } +global-utils = { path = "crates/global-utils" } +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"] } +serde_json = { version = "1.0.145" } +thiserror = { version = "2.0.17" } +tokio = { version = "1.48.0", features = ["macros", "test-util", "rt", "rt-multi-thread"] } +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/Makefile b/Makefile new file mode 100644 index 0000000..46ee2af --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +## This help screen +help: + @printf "Available targets:\n\n" + @awk '/^[a-zA-Z\-\_0-9%:\\]+/ { \ + helpMessage = match(lastLine, /^## (.*)/); \ + if (helpMessage) { \ + helpCommand = $$1; \ + helpMessage = substr(lastLine, RSTART + 3, RLENGTH); \ + gsub("\\\\", "", helpCommand); \ + gsub(":+$$", "", helpCommand); \ + printf " \x1b[32;01m%-35s\x1b[0m %s\n", helpCommand, helpMessage; \ + } \ + } \ + { lastLine = $$0 }' $(MAKEFILE_LIST) | sort -u + @printf "\n" + +## Format code +fmt: + cargo +nightly fmt --all + +## Show lints +clippy: + cargo clippy -- -Dclippy::pedantic + +## Show lints for all features +clippy_all_features: + cargo clippy --workspace --all-targets --all-features -- -D warnings \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..3381aa6 --- /dev/null +++ b/Readme.md @@ -0,0 +1,125 @@ +# 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 new file mode 100644 index 0000000..6cc0d2e --- /dev/null +++ b/crates/dex-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "simplicity-dex" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = { workspace = true } +nostr = { workspace = true } +global-utils = { workspace = true } +futures-util = { workspace = true } +serde_json = { 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/cli.rs b/crates/dex-cli/src/cli.rs new file mode 100644 index 0000000..78ac270 --- /dev/null +++ b/crates/dex-cli/src/cli.rs @@ -0,0 +1,167 @@ +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/error.rs b/crates/dex-cli/src/error.rs new file mode 100644 index 0000000..19d1340 --- /dev/null +++ b/crates/dex-cli/src/error.rs @@ -0,0 +1,15 @@ +use crate::utils::FileError; + +use dex_nostr_relay::error::NostrRelayError; + +pub type Result = core::result::Result; + +#[derive(thiserror::Error, Debug)] +pub enum CliError { + #[error("Occurred error with io, err: {0}")] + Io(#[from] std::io::Error), + #[error(transparent)] + File(#[from] FileError), + #[error(transparent)] + NostrRelay(#[from] NostrRelayError), +} diff --git a/crates/dex-cli/src/lib.rs b/crates/dex-cli/src/lib.rs new file mode 100644 index 0000000..fe25d5b --- /dev/null +++ b/crates/dex-cli/src/lib.rs @@ -0,0 +1,3 @@ +pub mod cli; +pub mod error; +mod utils; diff --git a/crates/dex-cli/src/main.rs b/crates/dex-cli/src/main.rs new file mode 100644 index 0000000..bdb09a3 --- /dev/null +++ b/crates/dex-cli/src/main.rs @@ -0,0 +1,15 @@ +use clap::Parser; + +use global_utils::logger::init_logger; + +use simplicity_dex::cli::Cli; + +#[tokio::main] +#[tracing::instrument] +async fn main() -> anyhow::Result<()> { + let _logger_guard = init_logger(); + + Cli::parse().process().await?; + + Ok(()) +} diff --git a/crates/dex-cli/src/utils.rs b/crates/dex-cli/src/utils.rs new file mode 100644 index 0000000..69f3599 --- /dev/null +++ b/crates/dex-cli/src/utils.rs @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..894e5fa --- /dev/null +++ b/crates/dex-nostr-relay/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dex-nostr-relay" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +readme.workspace = true + +[dependencies] +anyhow = { workspace = true } +tokio = { workspace = true } +global-utils = { workspace = true } +nostr-sdk = { workspace = true } +nostr = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/dex-nostr-relay/src/error.rs b/crates/dex-nostr-relay/src/error.rs new file mode 100644 index 0000000..c28e6c2 --- /dev/null +++ b/crates/dex-nostr-relay/src/error.rs @@ -0,0 +1,18 @@ +use nostr::SignerError; +use nostr::filter::SingleLetterTagError; + +#[derive(thiserror::Error, Debug)] +pub enum NostrRelayError { + #[error("Signer error: {0}")] + Signer(#[from] SignerError), + #[error("Single letter error: {0}")] + SingleLetterTag(#[from] SingleLetterTagError), + #[error("Failed to convert custom url to RelayURL, err: {err_msg}")] + FailedToConvertRelayUrl { err_msg: String }, + #[error("An error occurred in Nostr Client, err: {0}")] + NostrClientFailure(#[from] nostr_sdk::client::Error), + #[error("Relay Client requires for operation signature, add key to the Client")] + MissingSigner, +} + +pub type Result = std::result::Result; diff --git a/crates/dex-nostr-relay/src/handlers/get_events.rs b/crates/dex-nostr-relay/src/handlers/get_events.rs new file mode 100644 index 0000000..94c5324 --- /dev/null +++ b/crates/dex-nostr-relay/src/handlers/get_events.rs @@ -0,0 +1,24 @@ +pub mod ids { + use crate::relay_client::RelayClient; + + use std::collections::{BTreeMap, BTreeSet}; + + use nostr::{EventId, Filter}; + use nostr_sdk::prelude::Events; + + 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?; + Ok(events) + } +} diff --git a/crates/dex-nostr-relay/src/handlers/list_orders.rs b/crates/dex-nostr-relay/src/handlers/list_orders.rs new file mode 100644 index 0000000..ed1baa6 --- /dev/null +++ b/crates/dex-nostr-relay/src/handlers/list_orders.rs @@ -0,0 +1,38 @@ +use crate::types::{CustomKind, MakerOrderKind}; + +use crate::relay_client::RelayClient; + +use std::collections::{BTreeMap, BTreeSet}; + +use nostr::{Filter, 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?; + + let events = filter_expired_events(events); + Ok(events) +} + +#[inline] +fn filter_expired_events(events_to_filter: Events) -> Events { + let time_now = Timestamp::now(); + events_to_filter + .into_iter() + .filter(|x| match x.tags.expiration() { + None => false, + 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 new file mode 100644 index 0000000..62df426 --- /dev/null +++ b/crates/dex-nostr-relay/src/handlers/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod get_events; +pub(crate) mod list_orders; +pub(crate) mod order_replies; +pub(crate) mod place_order; +pub(crate) mod reply_order; diff --git a/crates/dex-nostr-relay/src/handlers/order_replies.rs b/crates/dex-nostr-relay/src/handlers/order_replies.rs new file mode 100644 index 0000000..5de0059 --- /dev/null +++ b/crates/dex-nostr-relay/src/handlers/order_replies.rs @@ -0,0 +1,23 @@ +use crate::relay_client::RelayClient; +use crate::types::{CustomKind, TakerOrderKind}; + +use std::collections::{BTreeMap, BTreeSet}; + +use nostr::{EventId, Filter, SingleLetterTag}; +use nostr_sdk::prelude::Events; + +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()])), + search: None, + since: None, + until: None, + limit: None, + generic_tags: BTreeMap::from([(SingleLetterTag::from_char('e')?, BTreeSet::from([event_id.to_string()]))]), + }) + .await?; + Ok(events) +} diff --git a/crates/dex-nostr-relay/src/handlers/place_order.rs b/crates/dex-nostr-relay/src/handlers/place_order.rs new file mode 100644 index 0000000..68d50fb --- /dev/null +++ b/crates/dex-nostr-relay/src/handlers/place_order.rs @@ -0,0 +1,35 @@ +use crate::relay_client::RelayClient; +use crate::relay_processor::OrderPlaceEventTags; +use crate::types::{BLOCKSTREAM_MAKER_CONTENT, CustomKind, MAKER_EXPIRATION_TIME, MakerOrderKind}; + +use std::borrow::Cow; + +use nostr::{EventBuilder, EventId, Tag, TagKind, Timestamp}; + +pub async fn handle(client: &RelayClient, tags: OrderPlaceEventTags) -> 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 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()]), + ]) + .custom_created_at(timestamp_now); + + let text_note = maker_order.build(client_pubkey); + let signed_event = client_signer.sign_event(text_note).await?; + + let maker_order_event_id = client.publish_event(&signed_event).await?; + + Ok(maker_order_event_id) +} diff --git a/crates/dex-nostr-relay/src/handlers/reply_order.rs b/crates/dex-nostr-relay/src/handlers/reply_order.rs new file mode 100644 index 0000000..dc5a532 --- /dev/null +++ b/crates/dex-nostr-relay/src/handlers/reply_order.rs @@ -0,0 +1,35 @@ +use crate::relay_client::RelayClient; +use crate::relay_processor::OrderReplyEventTags; +use crate::types::{BLOCKSTREAM_TAKER_CONTENT, CustomKind, TakerOrderKind}; + +use std::borrow::Cow; + +use nostr::{EventBuilder, EventId, NostrSigner, PublicKey, Tag, TagKind, Timestamp}; + +pub async fn handle( + client: &RelayClient, + maker_event_id: EventId, + maker_pubkey: PublicKey, + tags: OrderReplyEventTags, +) -> 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]), + ]) + .custom_created_at(timestamp_now); + + let reply_event = taker_response.build(client_pubkey); + let reply_event = client_signer.sign_event(reply_event).await?; + + let event_id = client.publish_event(&reply_event).await?; + + Ok(event_id) +} diff --git a/crates/dex-nostr-relay/src/lib.rs b/crates/dex-nostr-relay/src/lib.rs new file mode 100644 index 0000000..cee9880 --- /dev/null +++ b/crates/dex-nostr-relay/src/lib.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod handlers; +pub mod relay_client; +pub mod relay_processor; +pub mod types; diff --git a/crates/dex-nostr-relay/src/relay_client.rs b/crates/dex-nostr-relay/src/relay_client.rs new file mode 100644 index 0000000..db8bc52 --- /dev/null +++ b/crates/dex-nostr-relay/src/relay_client.rs @@ -0,0 +1,123 @@ +use crate::error::NostrRelayError; + +use std::collections::HashMap; +use std::fmt::Debug; +use std::sync::Arc; +use std::time::Duration; + +use nostr::prelude::*; +use nostr_sdk::pool::Output; +use nostr_sdk::prelude::Events; +use nostr_sdk::{Client, Relay, SubscribeAutoCloseOptions}; + +use tracing::instrument; + +#[derive(Debug)] +pub struct RelayClient { + client: Client, + timeout: Duration, +} + +#[derive(Debug)] +pub struct ClientConfig { + pub timeout: Duration, +} + +impl RelayClient { + #[instrument(skip_all, level = "debug", err)] + pub async fn connect( + relay_urls: impl IntoIterator, + keys: Option, + client_config: ClientConfig, + ) -> crate::error::Result { + tracing::debug!(client_config = ?client_config, "Connecting to Nostr Relay Client(s)"); + + let client = match keys { + None => Client::default(), + Some(keys) => { + let client = Client::new(keys); + client.automatic_authentication(true); + client + } + }; + + for url in relay_urls { + let url = url + .try_into_url() + .map_err(|err| NostrRelayError::FailedToConvertRelayUrl { + err_msg: format!("{:?}", err), + })?; + + client.add_relay(url).await?; + } + + client.connect().await; + + Ok(Self { + client, + timeout: client_config.timeout, + }) + } + + #[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"); + + Ok(self.client.fetch_combined_events(filter, self.timeout).await?) + } + + #[instrument(skip_all, level = "debug", ret)] + pub async fn get_signer(&self) -> crate::error::Result> { + if !self.client.has_signer().await { + return Err(NostrRelayError::MissingSigner); + } + + Ok(self.client.signer().await?) + } + + #[instrument(skip_all, level = "debug", ret)] + pub async fn get_relays(&self) -> HashMap { + self.client.relays().await + } + + #[instrument(skip_all, level = "debug", ret)] + pub async fn publish_event(&self, event: &Event) -> crate::error::Result { + if !self.client.has_signer().await { + return Err(NostrRelayError::MissingSigner); + } + + let event_id = self.client.send_event(event).await?; + let event_id = Self::handle_relay_output(event_id)?; + + Ok(event_id) + } + + #[instrument(skip(self), level = "debug")] + pub async fn subscribe( + &self, + filter: Filter, + opts: Option, + ) -> crate::error::Result { + Ok(self.client.subscribe(filter, opts).await?.val) + } + + #[instrument(skip(self), level = "debug")] + pub async fn unsubscribe(&self, subscription_id: &SubscriptionId) { + self.client.unsubscribe(subscription_id).await; + } + + #[instrument(skip_all, level = "debug", ret)] + pub async fn disconnect(&self) -> crate::error::Result<()> { + self.client.disconnect().await; + + Ok(()) + } + + /// TODO: handle error + #[instrument(level = "debug")] + fn handle_relay_output(output: Output) -> crate::error::Result { + tracing::debug!(output = ?output, "Handling Relay output."); + + Ok(output.val) + } +} diff --git a/crates/dex-nostr-relay/src/relay_processor.rs b/crates/dex-nostr-relay/src/relay_processor.rs new file mode 100644 index 0000000..cf9f554 --- /dev/null +++ b/crates/dex-nostr-relay/src/relay_processor.rs @@ -0,0 +1,63 @@ +use crate::handlers; +use crate::relay_client::{ClientConfig, RelayClient}; + +use nostr::prelude::IntoNostrSigner; +use nostr::{EventId, PublicKey, TryIntoUrl}; + +use nostr_sdk::prelude::Events; + +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, +} + +#[derive(Debug, Default, Clone)] +pub struct OrderReplyEventTags { + pub tx_id: String, +} + +impl RelayProcessor { + pub async fn try_from_config( + relay_urls: impl IntoIterator, + keys: Option, + client_config: ClientConfig, + ) -> crate::error::Result { + Ok(RelayProcessor { + relay_client: RelayClient::connect(relay_urls, keys, client_config).await?, + }) + } + + pub async fn place_order(&self, tags: OrderPlaceEventTags) -> crate::error::Result { + handlers::place_order::handle(&self.relay_client, tags).await + } + + pub async fn list_orders(&self) -> crate::error::Result { + handlers::list_orders::handle(&self.relay_client).await + } + + 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 + } + + pub async fn get_order_replies(&self, event_id: EventId) -> crate::error::Result { + handlers::order_replies::handle(&self.relay_client, event_id).await + } + + 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 + } +} diff --git a/crates/dex-nostr-relay/src/types.rs b/crates/dex-nostr-relay/src/types.rs new file mode 100644 index 0000000..bff1d57 --- /dev/null +++ b/crates/dex-nostr-relay/src/types.rs @@ -0,0 +1,30 @@ +use nostr::Kind; + +pub trait CustomKind { + const ORDER_KIND_NUMBER: u16; + + fn get_kind() -> Kind { + Kind::from(Self::ORDER_KIND_NUMBER) + } + + 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]"; + +// TODO: move to the config +pub const MAKER_EXPIRATION_TIME: u64 = 60; + +pub struct MakerOrderKind; +pub struct TakerOrderKind; + +impl CustomKind for MakerOrderKind { + const ORDER_KIND_NUMBER: u16 = 9901; +} + +impl CustomKind for TakerOrderKind { + const ORDER_KIND_NUMBER: u16 = 9902; +} diff --git a/crates/dex-nostr-relay/tests/test_order_placing.rs b/crates/dex-nostr-relay/tests/test_order_placing.rs new file mode 100644 index 0000000..3cb0735 --- /dev/null +++ b/crates/dex-nostr-relay/tests/test_order_placing.rs @@ -0,0 +1,92 @@ +mod utils; + +mod tests { + use crate::utils::{DEFAULT_CLIENT_TIMEOUT, DEFAULT_RELAY_LIST, TEST_LOGGER}; + + 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 tracing::{info, instrument}; + + #[instrument] + #[tokio::test] + async fn test_wss_metadata() -> anyhow::Result<()> { + let _guard = &*TEST_LOGGER; + let key_maker = Keys::generate(); + info!( + "=== Maker pubkey: {}, privatekey: {}", + key_maker.public_key.to_bech32()?, + key_maker.secret_key().to_bech32()? + ); + let relay_processor_maker = RelayProcessor::try_from_config( + DEFAULT_RELAY_LIST, + Some(key_maker.clone()), + ClientConfig { + timeout: Duration::from_secs(DEFAULT_CLIENT_TIMEOUT), + }, + ) + .await?; + + let placed_order_event_id = relay_processor_maker + .place_order(OrderPlaceEventTags::default()) + .await?; + info!("=== placed order event id: {}", placed_order_event_id); + let order = relay_processor_maker.get_events_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()); + + let key_taker = Keys::generate(); + let relay_processor_taker = RelayProcessor::try_from_config( + DEFAULT_RELAY_LIST, + Some(key_taker.clone()), + ClientConfig { + timeout: Duration::from_secs(DEFAULT_CLIENT_TIMEOUT), + }, + ) + .await?; + info!( + "=== Taker pubkey: {}, privatekey: {}", + 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); + + let order_replies = relay_processor_maker.get_order_replies(placed_order_event_id).await?; + info!( + "=== order replies, amount: {}, orders: {:#?}", + order_replies.len(), + order_replies + ); + 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?; + info!( + "=== orders listed, amount: {}, orders: {:#?}", + orders_listed.len(), + orders_listed + ); + assert!( + orders_listed + .iter() + .map(|x| x.id) + .collect::>() + .contains(&placed_order_event_id) + ); + + Ok(()) + } +} diff --git a/crates/dex-nostr-relay/tests/utils.rs b/crates/dex-nostr-relay/tests/utils.rs new file mode 100644 index 0000000..5c7d511 --- /dev/null +++ b/crates/dex-nostr-relay/tests/utils.rs @@ -0,0 +1,8 @@ +use std::sync::LazyLock; + +use global_utils::logger::{LoggerGuard, init_logger}; + +pub static TEST_LOGGER: LazyLock = LazyLock::new(init_logger); + +pub const DEFAULT_RELAY_LIST: [&str; 1] = ["wss://relay.damus.io"]; +pub const DEFAULT_CLIENT_TIMEOUT: u64 = 10; diff --git a/crates/global-utils/Cargo.toml b/crates/global-utils/Cargo.toml new file mode 100644 index 0000000..187a262 --- /dev/null +++ b/crates/global-utils/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "global-utils" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +readme.workspace = true + +[dependencies] +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } \ No newline at end of file diff --git a/crates/global-utils/src/lib.rs b/crates/global-utils/src/lib.rs new file mode 100644 index 0000000..d991728 --- /dev/null +++ b/crates/global-utils/src/lib.rs @@ -0,0 +1 @@ +pub mod logger; diff --git a/crates/global-utils/src/logger.rs b/crates/global-utils/src/logger.rs new file mode 100644 index 0000000..ecd0604 --- /dev/null +++ b/crates/global-utils/src/logger.rs @@ -0,0 +1,47 @@ +use std::io; + +use tracing::{level_filters::LevelFilter, trace}; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{EnvFilter, Layer, fmt, layer::SubscriberExt, util::SubscriberInitExt}; + +const ENV_VAR_NAME: &str = "DEX_LOG"; +const DEFAULT_LOG_DIRECTIVE: LevelFilter = LevelFilter::ERROR; + +#[derive(Debug)] +pub struct LoggerGuard { + _std_out_guard: WorkerGuard, + _std_err_guard: WorkerGuard, +} + +pub fn init_logger() -> LoggerGuard { + let (std_out_writer, std_out_guard) = tracing_appender::non_blocking(io::stdout()); + let (std_err_writer, std_err_guard) = tracing_appender::non_blocking(io::stderr()); + let std_out_layer = fmt::layer() + .with_writer(std_out_writer) + .with_target(false) + .with_level(true) + .with_filter( + EnvFilter::builder() + .with_default_directive(DEFAULT_LOG_DIRECTIVE.into()) + .with_env_var(ENV_VAR_NAME) + .from_env_lossy(), + ); + + let std_err_layer = fmt::layer() + .with_writer(std_err_writer) + .with_target(false) + .with_level(true) + .with_filter(LevelFilter::WARN); + + tracing_subscriber::registry() + .with(std_out_layer) + .with(std_err_layer) + .init(); + + trace!("Logger successfully initialized!"); + + LoggerGuard { + _std_out_guard: std_out_guard, + _std_err_guard: std_err_guard, + } +} diff --git a/docs/maker_taker_flow.puml b/docs/maker_taker_flow.puml new file mode 100644 index 0000000..2944eda --- /dev/null +++ b/docs/maker_taker_flow.puml @@ -0,0 +1,21 @@ +@startuml +'https://plantuml.com/sequence-diagram + +autonumber + +== Order matching == + +Maker -> LiquidNetwork: Create Option +Maker -> NostrRelay: Place order +Taker --> NostrRelay: Sync data +Taker --> Taker: Check available Option +Taker -> LiquidNetwork: Make transaction to fund Maker's order +Taker -> NostrRelay: Place own respond ack on the Maker's order + +== After 30 days == + +Taker <--> PriceOracle: Obtain information \n about current token price +Taker -> LiquidNetwork: Execute Option +Maker <--> PriceOracle: Obtain information \n about current token price +Maker -> LiquidNetwork: Execute Option +@enduml \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..5e4210e --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,27 @@ +# WARNING: Formatting in this project is non-standard and unfortunetely `cargo fmt` does not support "out of the box" formatting. +# Here you can find the closest possible set of settings for `cargo fmt`, but it is not even close to desirable. +# use '+nightly' option for formatting (cargo +nightly fmt) + +edition = "2024" +style_edition = "2024" +max_width = 120 +tab_spaces = 4 +newline_style = "Unix" +fn_params_layout = "Tall" +match_arm_leading_pipes = "Preserve" +reorder_imports = true +reorder_modules = true +# unstable features below +# unstable_features = true +# format_code_in_doc_comments = true +# imports_granularity = "Crate" +# group_imports = "StdExternalCrate" + +#wrap_comments = true +#where_single_line = false +#blank_lines_upper_bound = 2 +#brace_style = "AlwaysNextLine" +#control_brace_style = "AlwaysNextLine" +#empty_item_single_line = true +#use_small_heuristics = "Off" +