├── .env-sample ├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── archs ├── cross-compile.sh ├── rust-toolchain.toml ├── src ├── cli.rs ├── cli │ ├── add_invoice.rs │ ├── conversation_key.rs │ ├── get_dm.rs │ ├── list_disputes.rs │ ├── list_orders.rs │ ├── new_order.rs │ ├── rate_user.rs │ ├── send_dm.rs │ ├── send_msg.rs │ ├── take_buy.rs │ ├── take_dispute.rs │ └── take_sell.rs ├── db.rs ├── error.rs ├── fiat.rs ├── lib.rs ├── lightning │ └── mod.rs ├── main.rs ├── nip33.rs ├── pretty_table.rs └── util.rs └── static └── logo.png /.env-sample: -------------------------------------------------------------------------------- 1 | # Mostro pubkey in npub format 2 | MOSTRO_PUBKEY='npub1...' 3 | # Comma-separated list of relays 4 | RELAYS='wss://relay.nostr.vision,wss://nostr.zebedee.cloud,wss://nostr.slothy.win,wss://nostr.rewardsbunny.com,wss://nostr.supremestack.xyz,wss://nostr.shawnyeager.net,wss://relay.nostrmoto.xyz,wss://nostr.roundrockbitcoiners.com' 5 | POW='0' -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .env 3 | .vscode/ 4 | bin/ -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mostro-cli" 3 | version = "0.11.0" 4 | edition = "2021" 5 | license = "MIT" 6 | authors = [ 7 | "Francisco Calderón ", 8 | "Baba O'reily ", 9 | ] 10 | description = "Mostro P2P cli client" 11 | homepage = "https://mostro.network" 12 | repository = "https://github.com/MostroP2P/mostro-cli" 13 | 14 | [lib] 15 | name = "mostro_client" 16 | path = "src/lib.rs" 17 | 18 | [[bin]] 19 | name = "mostro-cli" 20 | path = "src/main.rs" 21 | 22 | [dependencies] 23 | anyhow = "1.0.68" 24 | clap = { version = "4.0", features = ["derive"] } 25 | nostr-sdk = { version = "0.41.0", features = ["nip06", "nip44", "nip59"] } 26 | serde = "1.0.215" 27 | serde_json = "1.0.91" 28 | tokio = { version = "1.23.0", features = ["full"] } 29 | comfy-table = "7.0.1" 30 | chrono = "0.4.23" 31 | log = "0.4.17" 32 | futures = "0.3" 33 | uuid = { version = "1.3.0", features = [ 34 | "v4", 35 | "fast-rng", 36 | "macro-diagnostics", 37 | "serde", 38 | ] } 39 | dotenvy = "0.15.6" 40 | lightning-invoice = { version = "0.32.0", features = ["std"] } 41 | reqwest = { version = "0.12.4", features = ["json"] } 42 | mostro-core = "0.6.42" 43 | lnurl-rs = "0.9.0" 44 | pretty_env_logger = "0.5.0" 45 | openssl = { version = "0.10.68", features = ["vendored"] } 46 | sqlx = { version = "0.8.2", features = ["sqlite", "runtime-tokio-native-tls"] } 47 | bip39 = { version = "2.1.0", features = ["rand"] } 48 | dirs = "5.0.1" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Francisco Calderón 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mostro CLI 🧌 2 | 3 | ![Mostro-logo](static/logo.png) 4 | 5 | Very simple command line interface that show all new replaceable events from [Mostro](https://github.com/MostroP2P/mostro) 6 | 7 | ## Requirements: 8 | 9 | 0. You need Rust version 1.64 or higher to compile. 10 | 1. You will need a lightning network node 11 | 12 | ## Install dependencies: 13 | 14 | To compile on Ubuntu/Pop!\_OS, please install [cargo](https://www.rust-lang.org/tools/install), then run the following commands: 15 | 16 | ``` 17 | $ sudo apt update 18 | $ sudo apt install -y cmake build-essential pkg-config 19 | ``` 20 | 21 | ## Install 22 | 23 | To install you need to fill the env vars (`.env`) on the with your own private key and add a Mostro pubkey. 24 | 25 | ``` 26 | $ git clone https://github.com/MostroP2P/mostro-cli.git 27 | $ cd mostro-cli 28 | $ cp .env-sample .env 29 | $ cargo run 30 | ``` 31 | 32 | # Usage 33 | 34 | ``` 35 | Commands: 36 | listorders Requests open orders from Mostro pubkey 37 | neworder Create a new buy/sell order on Mostro 38 | takesell Take a sell order from a Mostro pubkey 39 | takebuy Take a buy order from a Mostro pubkey 40 | addinvoice Buyer add a new invoice to receive the payment 41 | getdm Get the latest direct messages from Mostro 42 | fiatsent Send fiat sent message to confirm payment to other user 43 | release Settle the hold invoice and pay to buyer 44 | cancel Cancel a pending order 45 | rate Rate counterpart after a successful trade 46 | dispute Start a dispute 47 | admcancel Cancel an order (only admin) 48 | admsettle Settle a seller's hold invoice (only admin) 49 | admlistdisputes Requests open disputes from Mostro pubkey 50 | admaddsolver Add a new dispute's solver (only admin) 51 | admtakedispute Admin or solver take a Pending dispute (only admin) 52 | help Print this message or the help of the given subcommand(s) 53 | 54 | Options: 55 | -v, --verbose 56 | -m, --mostropubkey 57 | -r, --relays 58 | -p, --pow 59 | -h, --help Print help 60 | -V, --version Print version 61 | ``` 62 | 63 | # Examples 64 | 65 | ``` 66 | $ mostro-cli -m npub1ykvsmrmw2hk7jgxgy64zr8tfkx4nnjhq9eyfxdlg3caha3ph0skq6jr3z0 -r 'wss://nos.lol,wss://relay.damus.io,wss://nostr-pub.wellorder.net,wss://nostr.mutinywallet.com,wss://relay.nostr.band,wss://nostr.cizmar.net,wss://140.f7z.io,wss://nostrrelay.com,wss://relay.nostrr.de' listorders 67 | 68 | # You can set the env vars to avoid the -m, -n and -r flags 69 | $ export MOSTROPUBKEY=npub1ykvsmrmw2hk7jgxgy64zr8tfkx4nnjhq9eyfxdlg3caha3ph0skq6jr3z0 70 | $ export RELAYS='wss://nos.lol,wss://relay.damus.io,wss://nostr-pub.wellorder.net,wss://nostr.mutinywallet.com,wss://relay.nostr.band,wss://nostr.cizmar.net,wss://140.f7z.io,wss://nostrrelay.com,wss://relay.nostrr.de' 71 | $ mostro-cli listorders 72 | 73 | # Create a new buy order 74 | $ mostro-cli neworder -k buy -c ves -f 1000 -m "face to face" 75 | 76 | # Cancel a pending order 77 | $ mostro-cli cancel -o eb5740f6-e584-46c5-953a-29bc3eb818f0 78 | 79 | # Create a new sell range order with Proof or work difficulty of 10 80 | $ mostro-cli neworder -p 10 -k sell -c ars -f 1000-10000 -m "face to face" 81 | ``` 82 | 83 | ## Progress Overview 84 | 85 | - [x] Displays order list 86 | - [x] Take orders (Buy & Sell) 87 | - [x] Posts Orders (Buy & Sell) 88 | - [x] Sell flow 89 | - [x] Buy flow 90 | - [x] Maker cancel pending order 91 | - [x] Cooperative cancellation 92 | - [x] Buyer: add new invoice if payment fails 93 | - [x] Rate users 94 | - [x] Dispute flow (users) 95 | - [x] Dispute management (for admins) 96 | - [x] Create buy orders with LN address 97 | - [x] Direct message with peers (use nip-17) 98 | - [x] Conversation key management 99 | - [x] Add a new dispute's solver (for admins) 100 | - [ ] Identity management (Nip-06 support) 101 | - [ ] List own orders 102 | -------------------------------------------------------------------------------- /archs: -------------------------------------------------------------------------------- 1 | arm-unknown-linux-gnueabi 2 | x86_64-unknown-linux-musl 3 | aarch64-unknown-linux-musl 4 | armv7-unknown-linux-gnueabi 5 | x86_64-pc-windows-gnu 6 | x86_64-unknown-freebsd 7 | aarch64-linux-android 8 | -------------------------------------------------------------------------------- /cross-compile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # To make this work you need to have cross installed 3 | # cargo install cross 4 | app="mostro-cli" 5 | file="archs" 6 | manifest="manifest.txt" 7 | arch=`cat $file` 8 | if [ ! -d bin ]; then 9 | mkdir bin 10 | fi 11 | rm bin/* 12 | for i in $arch; do 13 | echo "Cross compiling for $i" 14 | cross build --release --target $i 15 | filename=$app 16 | if [ $i == "x86_64-pc-windows-gnu" ]; then 17 | filename=$filename".exe" 18 | fi 19 | cd target/$i/release 20 | mkdir $i 21 | cp $filename $i/ 22 | sha256sum $i/$filename >> ../../../bin/$manifest 23 | tar -czf $app-$i.tar.gz $i 24 | sha256sum $app-$i.tar.gz >> ../../../bin/$manifest 25 | mv $app-$i.tar.gz ../../../bin 26 | rm -rf $i 27 | cd ../../../ 28 | done -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.82.0" 3 | profile = "minimal" 4 | components = ["clippy", "rust-docs", "rustfmt"] 5 | targets = ["wasm32-unknown-unknown"] -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | pub mod add_invoice; 2 | pub mod conversation_key; 3 | pub mod get_dm; 4 | pub mod list_disputes; 5 | pub mod list_orders; 6 | pub mod new_order; 7 | pub mod rate_user; 8 | pub mod send_dm; 9 | pub mod send_msg; 10 | pub mod take_buy; 11 | pub mod take_dispute; 12 | pub mod take_sell; 13 | 14 | use crate::cli::add_invoice::execute_add_invoice; 15 | use crate::cli::conversation_key::execute_conversation_key; 16 | use crate::cli::get_dm::execute_get_dm; 17 | use crate::cli::list_disputes::execute_list_disputes; 18 | use crate::cli::list_orders::execute_list_orders; 19 | use crate::cli::new_order::execute_new_order; 20 | use crate::cli::rate_user::execute_rate_user; 21 | use crate::cli::send_dm::execute_send_dm; 22 | use crate::cli::send_msg::execute_send_msg; 23 | use crate::cli::take_buy::execute_take_buy; 24 | use crate::cli::take_dispute::execute_take_dispute; 25 | use crate::cli::take_sell::execute_take_sell; 26 | use crate::db::{connect, User}; 27 | use crate::util; 28 | 29 | use anyhow::{Error, Result}; 30 | use clap::{Parser, Subcommand}; 31 | use nostr_sdk::prelude::*; 32 | use std::{ 33 | env::{set_var, var}, 34 | str::FromStr, 35 | }; 36 | use take_dispute::*; 37 | use uuid::Uuid; 38 | 39 | #[derive(Parser)] 40 | #[command( 41 | name = "mostro-cli", 42 | about = "A simple CLI to use Mostro P2P", 43 | author, 44 | help_template = "\ 45 | {before-help}{name} 🧌 46 | 47 | {about-with-newline} 48 | {author-with-newline} 49 | {usage-heading} {usage} 50 | 51 | {all-args}{after-help} 52 | ", 53 | version 54 | )] 55 | #[command(propagate_version = true)] 56 | #[command(arg_required_else_help(true))] 57 | pub struct Cli { 58 | #[command(subcommand)] 59 | pub command: Option, 60 | #[arg(short, long)] 61 | pub verbose: bool, 62 | #[arg(short, long)] 63 | pub mostropubkey: Option, 64 | #[arg(short, long)] 65 | pub relays: Option, 66 | #[arg(short, long)] 67 | pub pow: Option, 68 | #[arg(short, long)] 69 | pub secret: bool, 70 | } 71 | 72 | #[derive(Subcommand, Clone)] 73 | #[clap(rename_all = "lower")] 74 | pub enum Commands { 75 | /// Requests open orders from Mostro pubkey 76 | ListOrders { 77 | /// Status of the order 78 | #[arg(short, long)] 79 | status: Option, 80 | /// Currency selected 81 | #[arg(short, long)] 82 | currency: Option, 83 | /// Choose an order kind 84 | #[arg(short, long)] 85 | kind: Option, 86 | }, 87 | /// Create a new buy/sell order on Mostro 88 | NewOrder { 89 | /// Choose an order kind 90 | #[arg(short, long)] 91 | kind: String, 92 | /// Sats amount - leave empty for market price 93 | #[arg(short, long)] 94 | #[clap(default_value_t = 0)] 95 | amount: i64, 96 | /// Currency selected 97 | #[arg(short = 'c', long)] 98 | fiat_code: String, 99 | /// Fiat amount 100 | #[arg(short, long)] 101 | #[clap(value_parser=check_fiat_range)] 102 | fiat_amount: (i64, Option), 103 | /// Payment method 104 | #[arg(short = 'm', long)] 105 | payment_method: String, 106 | /// Premium on price 107 | #[arg(short, long)] 108 | #[clap(default_value_t = 0)] 109 | #[clap(allow_hyphen_values = true)] 110 | premium: i64, 111 | /// Invoice string 112 | #[arg(short, long)] 113 | invoice: Option, 114 | /// Expiration time of a pending Order, in days 115 | #[arg(short, long)] 116 | #[clap(default_value_t = 0)] 117 | expiration_days: i64, 118 | }, 119 | /// Take a sell order from a Mostro pubkey 120 | TakeSell { 121 | /// Order id 122 | #[arg(short, long)] 123 | order_id: Uuid, 124 | /// Invoice string 125 | #[arg(short, long)] 126 | invoice: Option, 127 | /// Amount of fiat to buy 128 | #[arg(short, long)] 129 | amount: Option, 130 | }, 131 | /// Take a buy order from a Mostro pubkey 132 | TakeBuy { 133 | /// Order id 134 | #[arg(short, long)] 135 | order_id: Uuid, 136 | /// Amount of fiat to sell 137 | #[arg(short, long)] 138 | amount: Option, 139 | }, 140 | /// Buyer add a new invoice to receive the payment 141 | AddInvoice { 142 | /// Order id 143 | #[arg(short, long)] 144 | order_id: Uuid, 145 | /// Invoice string 146 | #[arg(short, long)] 147 | invoice: String, 148 | }, 149 | /// Get the latest direct messages 150 | GetDm { 151 | /// Since time of the messages in minutes 152 | #[arg(short, long)] 153 | #[clap(default_value_t = 30)] 154 | since: i64, 155 | /// If true, get messages from counterparty, otherwise from Mostro 156 | #[arg(short)] 157 | from_user: bool, 158 | }, 159 | /// Get the latest direct messages for admin 160 | GetAdminDm { 161 | /// Since time of the messages in minutes 162 | #[arg(short, long)] 163 | #[clap(default_value_t = 30)] 164 | since: i64, 165 | /// If true, get messages from counterparty, otherwise from Mostro 166 | #[arg(short)] 167 | from_user: bool, 168 | }, 169 | /// Send direct message to a user 170 | SendDm { 171 | /// Pubkey of the counterpart 172 | #[arg(short, long)] 173 | pubkey: String, 174 | /// Order id 175 | #[arg(short, long)] 176 | order_id: Uuid, 177 | /// Message to send 178 | #[arg(short, long)] 179 | message: String, 180 | }, 181 | /// Send fiat sent message to confirm payment to other user 182 | FiatSent { 183 | /// Order id 184 | #[arg(short, long)] 185 | order_id: Uuid, 186 | }, 187 | /// Settle the hold invoice and pay to buyer. 188 | Release { 189 | /// Order id 190 | #[arg(short, long)] 191 | order_id: Uuid, 192 | }, 193 | /// Cancel a pending order 194 | Cancel { 195 | /// Order id 196 | #[arg(short, long)] 197 | order_id: Uuid, 198 | }, 199 | /// Rate counterpart after a successful trade 200 | Rate { 201 | /// Order id 202 | #[arg(short, long)] 203 | order_id: Uuid, 204 | /// Rating from 1 to 5 205 | #[arg(short, long)] 206 | rating: u8, 207 | }, 208 | /// Start a dispute 209 | Dispute { 210 | /// Order id 211 | #[arg(short, long)] 212 | order_id: Uuid, 213 | }, 214 | /// Cancel an order (only admin) 215 | AdmCancel { 216 | /// Order id 217 | #[arg(short, long)] 218 | order_id: Uuid, 219 | }, 220 | /// Settle a seller's hold invoice (only admin) 221 | AdmSettle { 222 | /// Order id 223 | #[arg(short, long)] 224 | order_id: Uuid, 225 | }, 226 | /// Requests open disputes from Mostro pubkey 227 | AdmListDisputes {}, 228 | /// Add a new dispute's solver (only admin) 229 | AdmAddSolver { 230 | /// npubkey 231 | #[arg(short, long)] 232 | npubkey: String, 233 | }, 234 | /// Admin or solver take a Pending dispute (only admin) 235 | AdmTakeDispute { 236 | /// Dispute id 237 | #[arg(short, long)] 238 | dispute_id: Uuid, 239 | }, 240 | /// Get the conversation key for direct messaging with a user 241 | ConversationKey { 242 | /// Pubkey of the counterpart 243 | #[arg(short, long)] 244 | pubkey: String, 245 | }, 246 | } 247 | 248 | // Check range with two values value 249 | fn check_fiat_range(s: &str) -> Result<(i64, Option)> { 250 | if s.contains('-') { 251 | let min: i64; 252 | let max: i64; 253 | 254 | // Get values from CLI 255 | let values: Vec<&str> = s.split('-').collect(); 256 | 257 | // Check if more than two values 258 | if values.len() > 2 { 259 | return Err(Error::msg("Wrong amount syntax")); 260 | }; 261 | 262 | // Get ranged command 263 | if let Err(e) = values[0].parse::() { 264 | return Err(e.into()); 265 | } else { 266 | min = values[0].parse().unwrap(); 267 | } 268 | 269 | if let Err(e) = values[1].parse::() { 270 | return Err(e.into()); 271 | } else { 272 | max = values[1].parse().unwrap(); 273 | } 274 | 275 | // Check min below max 276 | if min >= max { 277 | return Err(Error::msg("Range of values must be 100-200 for example...")); 278 | }; 279 | Ok((min, Some(max))) 280 | } else { 281 | match s.parse::() { 282 | Ok(s) => Ok((s, None)), 283 | Err(e) => Err(e.into()), 284 | } 285 | } 286 | } 287 | 288 | pub async fn run() -> Result<()> { 289 | let cli = Cli::parse(); 290 | 291 | // Init logger 292 | if cli.verbose { 293 | set_var("RUST_LOG", "info"); 294 | pretty_env_logger::init(); 295 | } 296 | 297 | if cli.mostropubkey.is_some() { 298 | set_var("MOSTRO_PUBKEY", cli.mostropubkey.unwrap()); 299 | } 300 | let pubkey = var("MOSTRO_PUBKEY").expect("$MOSTRO_PUBKEY env var needs to be set"); 301 | 302 | if cli.relays.is_some() { 303 | set_var("RELAYS", cli.relays.unwrap()); 304 | } 305 | 306 | if cli.pow.is_some() { 307 | set_var("POW", cli.pow.unwrap()); 308 | } 309 | 310 | if cli.secret { 311 | set_var("SECRET", "true"); 312 | } 313 | 314 | let pool = connect().await?; 315 | let identity_keys = User::get_identity_keys(&pool) 316 | .await 317 | .map_err(|e| anyhow::anyhow!("Failed to get identity keys: {}", e))?; 318 | 319 | let (trade_keys, trade_index) = User::get_next_trade_keys(&pool) 320 | .await 321 | .map_err(|e| anyhow::anyhow!("Failed to get trade keys: {}", e))?; 322 | 323 | // Mostro pubkey 324 | let mostro_key = PublicKey::from_str(&pubkey)?; 325 | 326 | // Call function to connect to relays 327 | let client = util::connect_nostr().await?; 328 | 329 | if let Some(cmd) = cli.command { 330 | match &cmd { 331 | Commands::ConversationKey { pubkey } => { 332 | execute_conversation_key(&trade_keys, PublicKey::from_str(pubkey)?).await? 333 | } 334 | Commands::ListOrders { 335 | status, 336 | currency, 337 | kind, 338 | } => execute_list_orders(kind, currency, status, mostro_key, &client).await?, 339 | Commands::TakeSell { 340 | order_id, 341 | invoice, 342 | amount, 343 | } => { 344 | execute_take_sell( 345 | order_id, 346 | invoice, 347 | *amount, 348 | &identity_keys, 349 | &trade_keys, 350 | trade_index, 351 | mostro_key, 352 | &client, 353 | ) 354 | .await? 355 | } 356 | Commands::TakeBuy { order_id, amount } => { 357 | execute_take_buy( 358 | order_id, 359 | *amount, 360 | &identity_keys, 361 | &trade_keys, 362 | trade_index, 363 | mostro_key, 364 | &client, 365 | ) 366 | .await? 367 | } 368 | Commands::AddInvoice { order_id, invoice } => { 369 | execute_add_invoice(order_id, invoice, &identity_keys, mostro_key, &client).await? 370 | } 371 | Commands::GetDm { since, from_user } => { 372 | execute_get_dm(since, trade_index, &client, *from_user, false).await? 373 | } 374 | Commands::GetAdminDm { since, from_user } => { 375 | execute_get_dm(since, trade_index, &client, *from_user, true).await? 376 | } 377 | Commands::FiatSent { order_id } 378 | | Commands::Release { order_id } 379 | | Commands::Dispute { order_id } 380 | | Commands::Cancel { order_id } => { 381 | execute_send_msg( 382 | cmd.clone(), 383 | Some(*order_id), 384 | Some(&identity_keys), 385 | mostro_key, 386 | &client, 387 | None, 388 | ) 389 | .await? 390 | } 391 | Commands::AdmAddSolver { npubkey } => { 392 | let id_key = match std::env::var("NSEC_PRIVKEY") { 393 | Ok(id_key) => Keys::parse(&id_key)?, 394 | Err(e) => { 395 | println!("Failed to get mostro admin private key: {}", e); 396 | std::process::exit(1); 397 | } 398 | }; 399 | execute_admin_add_solver(npubkey, &id_key, &trade_keys, mostro_key, &client).await? 400 | } 401 | Commands::NewOrder { 402 | kind, 403 | fiat_code, 404 | amount, 405 | fiat_amount, 406 | payment_method, 407 | premium, 408 | invoice, 409 | expiration_days, 410 | } => { 411 | execute_new_order( 412 | kind, 413 | fiat_code, 414 | fiat_amount, 415 | amount, 416 | payment_method, 417 | premium, 418 | invoice, 419 | &identity_keys, 420 | &trade_keys, 421 | trade_index, 422 | mostro_key, 423 | &client, 424 | expiration_days, 425 | ) 426 | .await? 427 | } 428 | Commands::Rate { order_id, rating } => { 429 | execute_rate_user(order_id, rating, &identity_keys, mostro_key, &client).await?; 430 | } 431 | Commands::AdmSettle { order_id } => { 432 | let id_key = match std::env::var("NSEC_PRIVKEY") { 433 | Ok(id_key) => Keys::parse(&id_key)?, 434 | Err(e) => { 435 | println!("Failed to get mostro admin private key: {}", e); 436 | std::process::exit(1); 437 | } 438 | }; 439 | execute_admin_settle_dispute(order_id, &id_key, &trade_keys, mostro_key, &client) 440 | .await?; 441 | } 442 | Commands::AdmCancel { order_id } => { 443 | let id_key = match std::env::var("NSEC_PRIVKEY") { 444 | Ok(id_key) => Keys::parse(&id_key)?, 445 | Err(e) => { 446 | println!("Failed to get mostro admin private key: {}", e); 447 | std::process::exit(1); 448 | } 449 | }; 450 | execute_admin_cancel_dispute(order_id, &id_key, &trade_keys, mostro_key, &client) 451 | .await?; 452 | } 453 | Commands::AdmTakeDispute { dispute_id } => { 454 | let id_key = match std::env::var("NSEC_PRIVKEY") { 455 | Ok(id_key) => Keys::parse(&id_key)?, 456 | Err(e) => { 457 | println!("Failed to get mostro admin private key: {}", e); 458 | std::process::exit(1); 459 | } 460 | }; 461 | 462 | execute_take_dispute(dispute_id, &id_key, &trade_keys, mostro_key, &client).await? 463 | } 464 | Commands::AdmListDisputes {} => execute_list_disputes(mostro_key, &client).await?, 465 | Commands::SendDm { 466 | pubkey, 467 | order_id, 468 | message, 469 | } => { 470 | let pubkey = PublicKey::from_str(pubkey)?; 471 | execute_send_dm(pubkey, &client, order_id, message).await? 472 | } 473 | }; 474 | } 475 | 476 | println!("Bye Bye!"); 477 | 478 | Ok(()) 479 | } 480 | -------------------------------------------------------------------------------- /src/cli/add_invoice.rs: -------------------------------------------------------------------------------- 1 | use crate::db::connect; 2 | use crate::util::send_message_sync; 3 | use crate::{db::Order, lightning::is_valid_invoice}; 4 | use anyhow::Result; 5 | use lnurl::lightning_address::LightningAddress; 6 | use mostro_core::prelude::*; 7 | use nostr_sdk::prelude::*; 8 | use std::str::FromStr; 9 | use uuid::Uuid; 10 | 11 | pub async fn execute_add_invoice( 12 | order_id: &Uuid, 13 | invoice: &str, 14 | identity_keys: &Keys, 15 | mostro_key: PublicKey, 16 | client: &Client, 17 | ) -> Result<()> { 18 | let pool = connect().await?; 19 | let mut order = Order::get_by_id(&pool, &order_id.to_string()).await?; 20 | let trade_keys = order 21 | .trade_keys 22 | .clone() 23 | .ok_or(anyhow::anyhow!("Missing trade keys"))?; 24 | let trade_keys = Keys::parse(&trade_keys)?; 25 | 26 | println!( 27 | "Sending a lightning invoice {} to mostro pubId {}", 28 | order_id, mostro_key 29 | ); 30 | // Check invoice string 31 | let ln_addr = LightningAddress::from_str(invoice); 32 | let payload = if ln_addr.is_ok() { 33 | Some(Payload::PaymentRequest(None, invoice.to_string(), None)) 34 | } else { 35 | match is_valid_invoice(invoice) { 36 | Ok(i) => Some(Payload::PaymentRequest(None, i.to_string(), None)), 37 | Err(e) => { 38 | println!("Invalid invoice: {}", e); 39 | None 40 | } 41 | } 42 | }; 43 | let request_id = Uuid::new_v4().as_u128() as u64; 44 | // Create AddInvoice message 45 | let add_invoice_message = Message::new_order( 46 | Some(*order_id), 47 | Some(request_id), 48 | None, 49 | Action::AddInvoice, 50 | payload, 51 | ); 52 | 53 | let dm = send_message_sync( 54 | client, 55 | Some(identity_keys), 56 | &trade_keys, 57 | mostro_key, 58 | add_invoice_message, 59 | true, 60 | false, 61 | ) 62 | .await?; 63 | 64 | dm.iter().for_each(|el| { 65 | let message = el.0.get_inner_message_kind(); 66 | if message.request_id == Some(request_id) && message.action == Action::WaitingSellerToPay { 67 | println!("Now we should wait for the seller to pay the invoice"); 68 | } 69 | }); 70 | match order 71 | .set_status(Status::WaitingPayment.to_string()) 72 | .save(&pool) 73 | .await 74 | { 75 | Ok(_) => println!("Order status updated"), 76 | Err(e) => println!("Failed to update order status: {}", e), 77 | } 78 | 79 | Ok(()) 80 | } 81 | -------------------------------------------------------------------------------- /src/cli/conversation_key.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use nip44::v2::ConversationKey; 3 | use nostr_sdk::prelude::*; 4 | 5 | pub async fn execute_conversation_key(trade_keys: &Keys, receiver: PublicKey) -> Result<()> { 6 | // Derive conversation key 7 | let ck = ConversationKey::derive(trade_keys.secret_key(), &receiver)?; 8 | let key = ck.as_bytes(); 9 | let mut ck_hex = vec![]; 10 | for i in key { 11 | ck_hex.push(format!("{:02x}", i)); 12 | } 13 | let ck_hex = ck_hex.join(""); 14 | println!("Conversation key: {:?}", ck_hex); 15 | 16 | Ok(()) 17 | } 18 | -------------------------------------------------------------------------------- /src/cli/get_dm.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use chrono::DateTime; 3 | use mostro_core::prelude::*; 4 | use nostr_sdk::prelude::*; 5 | 6 | use crate::{ 7 | db::{connect, Order, User}, 8 | util::get_direct_messages, 9 | }; 10 | 11 | pub async fn execute_get_dm( 12 | since: &i64, 13 | trade_index: i64, 14 | client: &Client, 15 | from_user: bool, 16 | admin: bool, 17 | ) -> Result<()> { 18 | let mut dm: Vec<(Message, u64)> = Vec::new(); 19 | let pool = connect().await?; 20 | if !admin { 21 | for index in 1..=trade_index { 22 | let keys = User::get_trade_keys(&pool, index).await?; 23 | let dm_temp = get_direct_messages(client, &keys, *since, from_user).await; 24 | dm.extend(dm_temp); 25 | } 26 | } else { 27 | let id_key = match std::env::var("NSEC_PRIVKEY") { 28 | Ok(id_key) => Keys::parse(&id_key)?, 29 | Err(e) => { 30 | println!("Failed to get mostro admin private key: {}", e); 31 | std::process::exit(1); 32 | } 33 | }; 34 | let dm_temp = get_direct_messages(client, &id_key, *since, from_user).await; 35 | dm.extend(dm_temp); 36 | } 37 | 38 | if dm.is_empty() { 39 | println!(); 40 | println!("No new messages"); 41 | println!(); 42 | } else { 43 | for m in dm.iter() { 44 | let message = m.0.get_inner_message_kind(); 45 | let date = DateTime::from_timestamp(m.1 as i64, 0).unwrap(); 46 | if message.id.is_some() { 47 | println!( 48 | "Mostro sent you this message for order id: {} at {}", 49 | m.0.get_inner_message_kind().id.unwrap(), 50 | date 51 | ); 52 | } 53 | if let Some(payload) = &message.payload { 54 | match payload { 55 | Payload::PaymentRequest(_, inv, _) => { 56 | println!(); 57 | println!("Pay this invoice to continue --> {}", inv); 58 | println!(); 59 | } 60 | Payload::TextMessage(text) => { 61 | println!(); 62 | println!("{text}"); 63 | println!(); 64 | } 65 | Payload::Dispute(id, token, info) => { 66 | println!("Action: {}", message.action); 67 | println!("Dispute id: {}", id); 68 | if token.is_some() { 69 | println!("Dispute token: {}", token.unwrap()); 70 | } 71 | if info.is_some() { 72 | println!(); 73 | println!("Dispute info: {:#?}", info); 74 | println!(); 75 | } 76 | } 77 | Payload::CantDo(Some(cant_do_reason)) => { 78 | println!(); 79 | println!("Error: {:?}", cant_do_reason); 80 | println!(); 81 | } 82 | Payload::Order(new_order) if message.action == Action::NewOrder => { 83 | if new_order.id.is_some() { 84 | let db_order = 85 | Order::get_by_id(&pool, &new_order.id.unwrap().to_string()).await; 86 | if db_order.is_err() { 87 | let trade_index = message.trade_index.unwrap(); 88 | let trade_keys = User::get_trade_keys(&pool, trade_index).await?; 89 | let _ = Order::new(&pool, new_order.clone(), &trade_keys, None) 90 | .await 91 | .map_err(|e| { 92 | anyhow::anyhow!("Failed to create DB order: {:?}", e) 93 | })?; 94 | } 95 | } 96 | println!(); 97 | println!("Order: {:#?}", new_order); 98 | println!(); 99 | } 100 | _ => { 101 | println!(); 102 | println!("Action: {}", message.action); 103 | println!("Payload: {:#?}", message.payload); 104 | println!(); 105 | } 106 | } 107 | } else { 108 | println!(); 109 | println!("Action: {}", message.action); 110 | println!("Payload: {:#?}", message.payload); 111 | println!(); 112 | } 113 | } 114 | } 115 | Ok(()) 116 | } 117 | -------------------------------------------------------------------------------- /src/cli/list_disputes.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use nostr_sdk::prelude::*; 3 | 4 | use crate::pretty_table::print_disputes_table; 5 | use crate::util::get_disputes_list; 6 | 7 | pub async fn execute_list_disputes(mostro_key: PublicKey, client: &Client) -> Result<()> { 8 | println!( 9 | "Requesting disputes from mostro pubId - {}", 10 | mostro_key.clone() 11 | ); 12 | 13 | // Get orders from relays 14 | let table_of_disputes = get_disputes_list(mostro_key, client).await?; 15 | let table = print_disputes_table(table_of_disputes)?; 16 | println!("{table}"); 17 | 18 | Ok(()) 19 | } 20 | -------------------------------------------------------------------------------- /src/cli/list_orders.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use mostro_core::prelude::*; 3 | use nostr_sdk::prelude::*; 4 | use std::str::FromStr; 5 | 6 | use crate::pretty_table::print_orders_table; 7 | use crate::util::get_orders_list; 8 | 9 | pub async fn execute_list_orders( 10 | kind: &Option, 11 | currency: &Option, 12 | status: &Option, 13 | mostro_key: PublicKey, 14 | client: &Client, 15 | ) -> Result<()> { 16 | // Used to get upper currency string to check against a list of tickers 17 | let mut upper_currency: Option = None; 18 | let mut status_checked: Option = Some(Status::from_str("pending").unwrap()); 19 | let mut kind_checked: Option = None; 20 | 21 | // New check against strings 22 | if let Some(s) = status { 23 | status_checked = Some(Status::from_str(s).expect("Not valid status! Please check")); 24 | } 25 | 26 | println!( 27 | "You are searching orders with status {:?}", 28 | status_checked.unwrap() 29 | ); 30 | // New check against strings 31 | if let Some(k) = kind { 32 | kind_checked = Some(mostro_core::order::Kind::from_str(k).expect("Not valid order kind! Please check")); 33 | println!("You are searching {} orders", kind_checked.unwrap()); 34 | } 35 | 36 | // Uppercase currency 37 | if let Some(curr) = currency { 38 | upper_currency = Some(curr.to_uppercase()); 39 | println!( 40 | "You are searching orders with currency {}", 41 | upper_currency.clone().unwrap() 42 | ); 43 | } 44 | 45 | println!( 46 | "Requesting orders from mostro pubId - {}", 47 | mostro_key.clone() 48 | ); 49 | 50 | // Get orders from relays 51 | let table_of_orders = get_orders_list( 52 | mostro_key, 53 | status_checked.unwrap(), 54 | upper_currency, 55 | kind_checked, 56 | client, 57 | ) 58 | .await?; 59 | let table = print_orders_table(table_of_orders)?; 60 | println!("{table}"); 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /src/cli/new_order.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use mostro_core::prelude::*; 3 | use nostr_sdk::prelude::*; 4 | use std::collections::HashMap; 5 | use std::io::{stdin, stdout, BufRead, Write}; 6 | use std::process; 7 | use std::str::FromStr; 8 | use uuid::Uuid; 9 | 10 | use crate::db::{connect, Order, User}; 11 | use crate::pretty_table::print_order_preview; 12 | use crate::util::{send_message_sync, uppercase_first}; 13 | 14 | pub type FiatNames = HashMap; 15 | 16 | #[allow(clippy::too_many_arguments)] 17 | pub async fn execute_new_order( 18 | kind: &str, 19 | fiat_code: &str, 20 | fiat_amount: &(i64, Option), 21 | amount: &i64, 22 | payment_method: &str, 23 | premium: &i64, 24 | invoice: &Option, 25 | identity_keys: &Keys, 26 | trade_keys: &Keys, 27 | trade_index: i64, 28 | mostro_key: PublicKey, 29 | client: &Client, 30 | expiration_days: &i64, 31 | ) -> Result<()> { 32 | // Uppercase currency 33 | let fiat_code = fiat_code.to_uppercase(); 34 | // Check if fiat currency selected is available on Yadio and eventually force user to set amount 35 | // this is in the case of crypto <--> crypto offer for example 36 | if *amount == 0 { 37 | // Get Fiat list 38 | let api_req_string = "https://api.yadio.io/currencies".to_string(); 39 | let fiat_list_check = reqwest::get(api_req_string) 40 | .await? 41 | .json::() 42 | .await? 43 | .contains_key(&fiat_code); 44 | if !fiat_list_check { 45 | println!("{} is not present in the fiat market, please specify an amount with -a flag to fix the rate", fiat_code); 46 | process::exit(0); 47 | } 48 | } 49 | let kind = uppercase_first(kind); 50 | // New check against strings 51 | let kind_checked = mostro_core::order::Kind::from_str(&kind).unwrap(); 52 | let expires_at = match *expiration_days { 53 | 0 => None, 54 | _ => { 55 | let now = chrono::Utc::now(); 56 | let expires_at = now + chrono::Duration::days(*expiration_days); 57 | Some(expires_at.timestamp()) 58 | } 59 | }; 60 | 61 | // Get the type of neworder 62 | // if both tuple field are valid than it's a range order 63 | // otherwise use just fiat amount value as before 64 | let amt = if fiat_amount.1.is_some() { 65 | (0, Some(fiat_amount.0), fiat_amount.1) 66 | } else { 67 | (fiat_amount.0, None, None) 68 | }; 69 | let small_order = SmallOrder::new( 70 | None, 71 | Some(kind_checked), 72 | Some(Status::Pending), 73 | *amount, 74 | fiat_code.clone(), 75 | amt.1, 76 | amt.2, 77 | amt.0, 78 | payment_method.to_owned(), 79 | *premium, 80 | None, 81 | None, 82 | invoice.as_ref().to_owned().cloned(), 83 | Some(0), 84 | expires_at, 85 | None, 86 | None, 87 | ); 88 | 89 | // Create new order for mostro 90 | let order_content = Payload::Order(small_order.clone()); 91 | 92 | // Print order preview 93 | let ord_preview = print_order_preview(order_content.clone()).unwrap(); 94 | println!("{ord_preview}"); 95 | let mut user_input = String::new(); 96 | let _input = stdin(); 97 | print!("Check your order! Is it correct? (Y/n) > "); 98 | stdout().flush()?; 99 | 100 | let mut answer = stdin().lock(); 101 | answer.read_line(&mut user_input)?; 102 | 103 | match user_input.to_lowercase().as_str().trim_end() { 104 | "y" | "" => {} 105 | "n" => { 106 | println!("Ok you have cancelled the order, create another one please"); 107 | process::exit(0); 108 | } 109 | &_ => { 110 | println!("Can't get what you're sayin!"); 111 | process::exit(0); 112 | } 113 | }; 114 | let request_id = Uuid::new_v4().as_u128() as u64; 115 | // Create NewOrder message 116 | let message = Message::new_order( 117 | None, 118 | Some(request_id), 119 | Some(trade_index), 120 | Action::NewOrder, 121 | Some(order_content), 122 | ); 123 | 124 | let dm = send_message_sync( 125 | client, 126 | Some(identity_keys), 127 | trade_keys, 128 | mostro_key, 129 | message, 130 | true, 131 | false, 132 | ) 133 | .await?; 134 | let order_id = dm 135 | .iter() 136 | .find_map(|el| { 137 | let message = el.0.get_inner_message_kind(); 138 | if message.request_id == Some(request_id) { 139 | match message.action { 140 | Action::NewOrder => { 141 | if let Some(Payload::Order(order)) = message.payload.as_ref() { 142 | return order.id; 143 | } 144 | } 145 | Action::CantDo => { 146 | if let Some(Payload::CantDo(Some(cant_do_reason))) = &message.payload { 147 | match cant_do_reason { 148 | CantDoReason::OutOfRangeFiatAmount | CantDoReason::OutOfRangeSatsAmount => { 149 | println!("Error: Amount is outside the allowed range. Please check the order's min/max limits."); 150 | } 151 | _ => { 152 | println!("Unknown reason: {:?}", message.payload); 153 | } 154 | } 155 | } else { 156 | println!("Unknown reason: {:?}", message.payload); 157 | return None; 158 | } 159 | } 160 | _ => { 161 | println!("Unknown action: {:?}", message.action); 162 | return None; 163 | } 164 | } 165 | } 166 | None 167 | }) 168 | .or_else(|| { 169 | println!("Error: No matching order found in response"); 170 | None 171 | }); 172 | 173 | if let Some(order_id) = order_id { 174 | println!("Order id {} created", order_id); 175 | // Create order in db 176 | let pool = connect().await?; 177 | let db_order = Order::new(&pool, small_order, trade_keys, Some(request_id as i64)) 178 | .await 179 | .map_err(|e| anyhow::anyhow!("Failed to create DB order: {:?}", e))?; 180 | // Update last trade index 181 | match User::get(&pool).await { 182 | Ok(mut user) => { 183 | user.set_last_trade_index(trade_index); 184 | if let Err(e) = user.save(&pool).await { 185 | println!("Failed to update user: {}", e); 186 | } 187 | } 188 | Err(e) => println!("Failed to get user: {}", e), 189 | } 190 | let db_order_id = db_order 191 | .id 192 | .clone() 193 | .ok_or(anyhow::anyhow!("Missing order id"))?; 194 | Order::save_new_id(&pool, db_order_id, order_id.to_string()).await?; 195 | } 196 | Ok(()) 197 | } 198 | -------------------------------------------------------------------------------- /src/cli/rate_user.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use mostro_core::prelude::*; 3 | use nostr_sdk::prelude::*; 4 | use uuid::Uuid; 5 | 6 | use crate::{ 7 | db::{connect, Order}, 8 | util::send_message_sync, 9 | }; 10 | 11 | pub async fn execute_rate_user( 12 | order_id: &Uuid, 13 | rating: &u8, 14 | identity_keys: &Keys, 15 | mostro_key: PublicKey, 16 | client: &Client, 17 | ) -> Result<()> { 18 | // User rating 19 | let rating_content; 20 | 21 | // Check boundaries 22 | if let 1..=5 = *rating { 23 | rating_content = Payload::RatingUser(*rating); 24 | } else { 25 | println!("Rating must be in the range 1 - 5"); 26 | std::process::exit(0); 27 | } 28 | 29 | let pool = connect().await?; 30 | 31 | let trade_keys = if let Ok(order_to_vote) = Order::get_by_id(&pool, &order_id.to_string()).await 32 | { 33 | match order_to_vote.trade_keys.as_ref() { 34 | Some(trade_keys) => Keys::parse(trade_keys)?, 35 | None => { 36 | anyhow::bail!("No trade_keys found for this order"); 37 | } 38 | } 39 | } else { 40 | println!("order {} not found", order_id); 41 | std::process::exit(0) 42 | }; 43 | 44 | // Create rating message of counterpart 45 | let rate_message = Message::new_order( 46 | Some(*order_id), 47 | None, 48 | None, 49 | Action::RateUser, 50 | Some(rating_content), 51 | ); 52 | 53 | send_message_sync( 54 | client, 55 | Some(identity_keys), 56 | &trade_keys, 57 | mostro_key, 58 | rate_message, 59 | true, 60 | false, 61 | ) 62 | .await?; 63 | 64 | std::process::exit(0); 65 | } 66 | -------------------------------------------------------------------------------- /src/cli/send_dm.rs: -------------------------------------------------------------------------------- 1 | use crate::{db::Order, util::send_message_sync}; 2 | use anyhow::Result; 3 | use mostro_core::prelude::*; 4 | use nostr_sdk::prelude::*; 5 | use uuid::Uuid; 6 | 7 | pub async fn execute_send_dm( 8 | receiver: PublicKey, 9 | client: &Client, 10 | order_id: &Uuid, 11 | message: &str, 12 | ) -> Result<()> { 13 | let message = Message::new_dm( 14 | None, 15 | None, 16 | Action::SendDm, 17 | Some(Payload::TextMessage(message.to_string())), 18 | ); 19 | 20 | let pool = crate::db::connect().await?; 21 | 22 | let trade_keys = if let Ok(order_to_vote) = Order::get_by_id(&pool, &order_id.to_string()).await 23 | { 24 | match order_to_vote.trade_keys.as_ref() { 25 | Some(trade_keys) => Keys::parse(trade_keys)?, 26 | None => { 27 | anyhow::bail!("No trade_keys found for this order"); 28 | } 29 | } 30 | } else { 31 | println!("order {} not found", order_id); 32 | std::process::exit(0) 33 | }; 34 | 35 | send_message_sync(client, None, &trade_keys, receiver, message, true, true).await?; 36 | 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /src/cli/send_msg.rs: -------------------------------------------------------------------------------- 1 | use crate::db::{Order, User}; 2 | use crate::util::send_message_sync; 3 | use crate::{cli::Commands, db::connect}; 4 | 5 | use anyhow::Result; 6 | use mostro_core::prelude::*; 7 | use nostr_sdk::prelude::*; 8 | use sqlx::SqlitePool; 9 | use std::process; 10 | use uuid::Uuid; 11 | 12 | pub async fn execute_send_msg( 13 | command: Commands, 14 | order_id: Option, 15 | identity_keys: Option<&Keys>, 16 | mostro_key: PublicKey, 17 | client: &Client, 18 | text: Option<&str>, 19 | ) -> Result<()> { 20 | // Map CLI command to action 21 | let requested_action = match command { 22 | Commands::FiatSent { .. } => Action::FiatSent, 23 | Commands::Release { .. } => Action::Release, 24 | Commands::Cancel { .. } => Action::Cancel, 25 | Commands::Dispute { .. } => Action::Dispute, 26 | Commands::AdmCancel { .. } => Action::AdminCancel, 27 | Commands::AdmSettle { .. } => Action::AdminSettle, 28 | Commands::AdmAddSolver { .. } => Action::AdminAddSolver, 29 | _ => { 30 | eprintln!("Not a valid command!"); 31 | process::exit(0); 32 | } 33 | }; 34 | 35 | println!( 36 | "Sending {} command for order {:?} to mostro pubId {}", 37 | requested_action, order_id, mostro_key 38 | ); 39 | 40 | let pool = connect().await?; 41 | 42 | // Determine payload 43 | let payload = match requested_action { 44 | Action::FiatSent | Action::Release => create_next_trade_payload(&pool, &order_id).await?, 45 | _ => text.map(|t| Payload::TextMessage(t.to_string())), 46 | }; 47 | // Update last trade index if next trade payload 48 | if let Some(Payload::NextTrade(_, trade_index)) = &payload { 49 | // Update last trade index 50 | match User::get(&pool).await { 51 | Ok(mut user) => { 52 | user.set_last_trade_index(*trade_index as i64); 53 | if let Err(e) = user.save(&pool).await { 54 | println!("Failed to update user: {}", e); 55 | } 56 | } 57 | Err(e) => println!("Failed to get user: {}", e), 58 | } 59 | } 60 | 61 | let request_id = Uuid::new_v4().as_u128() as u64; 62 | 63 | // Create and send the message 64 | let message = Message::new_order(order_id, Some(request_id), None, requested_action, payload); 65 | // println!("Sending message: {:#?}", message); 66 | 67 | if let Some(order_id) = order_id { 68 | handle_order_response( 69 | &pool, 70 | client, 71 | identity_keys, 72 | mostro_key, 73 | message, 74 | order_id, 75 | request_id, 76 | ) 77 | .await?; 78 | } else { 79 | println!("Error: Missing order ID"); 80 | } 81 | 82 | Ok(()) 83 | } 84 | 85 | async fn create_next_trade_payload( 86 | pool: &SqlitePool, 87 | order_id: &Option, 88 | ) -> Result> { 89 | if let Some(order_id) = order_id { 90 | let order = Order::get_by_id(pool, &order_id.to_string()).await?; 91 | 92 | if let (Some(_), Some(min_amount), Some(max_amount)) = 93 | (order.is_mine, order.min_amount, order.max_amount) 94 | { 95 | if max_amount - order.fiat_amount >= min_amount { 96 | let (trade_keys, trade_index) = User::get_next_trade_keys(pool).await?; 97 | return Ok(Some(Payload::NextTrade( 98 | trade_keys.public_key().to_string(), 99 | trade_index.try_into()?, 100 | ))); 101 | } 102 | } 103 | } 104 | Ok(None) 105 | } 106 | 107 | async fn handle_order_response( 108 | pool: &SqlitePool, 109 | client: &Client, 110 | identity_keys: Option<&Keys>, 111 | mostro_key: PublicKey, 112 | message: Message, 113 | order_id: Uuid, 114 | request_id: u64, 115 | ) -> Result<()> { 116 | let order = Order::get_by_id(pool, &order_id.to_string()).await; 117 | 118 | match order { 119 | Ok(order) => { 120 | if let Some(trade_keys_str) = order.trade_keys { 121 | let trade_keys = Keys::parse(&trade_keys_str)?; 122 | let dm = send_message_sync( 123 | client, 124 | identity_keys, 125 | &trade_keys, 126 | mostro_key, 127 | message, 128 | true, 129 | false, 130 | ) 131 | .await?; 132 | process_order_response(dm, pool, &trade_keys, request_id).await?; 133 | } else { 134 | println!("Error: Missing trade keys for order {}", order_id); 135 | } 136 | } 137 | Err(e) => { 138 | println!("Error: {}", e); 139 | } 140 | } 141 | 142 | Ok(()) 143 | } 144 | 145 | async fn process_order_response( 146 | dm: Vec<(Message, u64)>, 147 | pool: &SqlitePool, 148 | trade_keys: &Keys, 149 | request_id: u64, 150 | ) -> Result<()> { 151 | for (message, _) in dm { 152 | let kind = message.get_inner_message_kind(); 153 | if let Some(req_id) = kind.request_id { 154 | if req_id != request_id { 155 | continue; 156 | } 157 | 158 | match kind.action { 159 | Action::NewOrder => { 160 | if let Some(Payload::Order(order)) = kind.payload.as_ref() { 161 | Order::new(pool, order.clone(), trade_keys, Some(request_id as i64)) 162 | .await 163 | .map_err(|e| anyhow::anyhow!("Failed to create new order: {}", e))?; 164 | return Ok(()); 165 | } 166 | } 167 | Action::Canceled => { 168 | if let Some(id) = kind.id { 169 | // Verify order exists before deletion 170 | if Order::get_by_id(pool, &id.to_string()).await.is_ok() { 171 | Order::delete_by_id(pool, &id.to_string()) 172 | .await 173 | .map_err(|e| anyhow::anyhow!("Failed to delete order: {}", e))?; 174 | return Ok(()); 175 | } else { 176 | return Err(anyhow::anyhow!("Order not found: {}", id)); 177 | } 178 | } 179 | } 180 | _ => (), 181 | } 182 | } 183 | } 184 | 185 | Ok(()) 186 | } 187 | -------------------------------------------------------------------------------- /src/cli/take_buy.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use mostro_core::prelude::*; 3 | use nostr_sdk::prelude::*; 4 | use uuid::Uuid; 5 | 6 | use crate::{ 7 | db::{connect, Order, User}, 8 | util::send_message_sync, 9 | }; 10 | 11 | pub async fn execute_take_buy( 12 | order_id: &Uuid, 13 | amount: Option, 14 | identity_keys: &Keys, 15 | trade_keys: &Keys, 16 | trade_index: i64, 17 | mostro_key: PublicKey, 18 | client: &Client, 19 | ) -> Result<()> { 20 | println!( 21 | "Request of take buy order {} from mostro pubId {}", 22 | order_id, 23 | mostro_key.clone() 24 | ); 25 | let request_id = Uuid::new_v4().as_u128() as u64; 26 | let payload = amount.map(|amt: u32| Payload::Amount(amt as i64)); 27 | // Create takebuy message 28 | let take_buy_message = Message::new_order( 29 | Some(*order_id), 30 | Some(request_id), 31 | Some(trade_index), 32 | Action::TakeBuy, 33 | payload, 34 | ); 35 | 36 | let dm = send_message_sync( 37 | client, 38 | Some(identity_keys), 39 | trade_keys, 40 | mostro_key, 41 | take_buy_message, 42 | true, 43 | false, 44 | ) 45 | .await?; 46 | 47 | let pool = connect().await?; 48 | 49 | let order = dm.iter().find_map(|el| { 50 | let message = el.0.get_inner_message_kind(); 51 | if message.request_id == Some(request_id) { 52 | match message.action { 53 | Action::PayInvoice => { 54 | if let Some(Payload::PaymentRequest(order, invoice, _)) = &message.payload { 55 | println!( 56 | "Mostro sent you this hold invoice for order id: {}", 57 | order 58 | .as_ref() 59 | .and_then(|o| o.id) 60 | .map_or("unknown".to_string(), |id| id.to_string()) 61 | ); 62 | println!(); 63 | println!("Pay this invoice to continue --> {}", invoice); 64 | println!(); 65 | return order.clone(); 66 | } 67 | } 68 | Action::CantDo => { 69 | if let Some(Payload::CantDo(Some(cant_do_reason))) = &message.payload { 70 | match cant_do_reason { 71 | CantDoReason::OutOfRangeFiatAmount | CantDoReason::OutOfRangeSatsAmount => { 72 | println!("Error: Amount is outside the allowed range. Please check the order's min/max limits."); 73 | } 74 | _ => { 75 | println!("Unknown reason: {:?}", message.payload); 76 | } 77 | } 78 | } else { 79 | println!("Unknown reason: {:?}", message.payload); 80 | return None; 81 | } 82 | } 83 | _ => { 84 | println!("Unknown action: {:?}", message.action); 85 | return None; 86 | } 87 | } 88 | } 89 | None 90 | }); 91 | if let Some(o) = order { 92 | match Order::new(&pool, o, trade_keys, Some(request_id as i64)).await { 93 | Ok(order) => { 94 | println!("Order {} created", order.id.unwrap()); 95 | // Update last trade index to be used in next trade 96 | match User::get(&pool).await { 97 | Ok(mut user) => { 98 | user.set_last_trade_index(trade_index); 99 | if let Err(e) = user.save(&pool).await { 100 | println!("Failed to update user: {}", e); 101 | } 102 | } 103 | Err(e) => println!("Failed to get user: {}", e), 104 | } 105 | } 106 | Err(e) => println!("{}", e), 107 | } 108 | } 109 | 110 | Ok(()) 111 | } 112 | -------------------------------------------------------------------------------- /src/cli/take_dispute.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use mostro_core::prelude::*; 3 | use nostr_sdk::prelude::*; 4 | use uuid::Uuid; 5 | 6 | use crate::util::send_message_sync; 7 | 8 | pub async fn execute_admin_add_solver( 9 | npubkey: &str, 10 | identity_keys: &Keys, 11 | trade_keys: &Keys, 12 | mostro_key: PublicKey, 13 | client: &Client, 14 | ) -> Result<()> { 15 | println!( 16 | "Request of add solver with pubkey {} from mostro pubId {}", 17 | npubkey, 18 | mostro_key.clone() 19 | ); 20 | // Create takebuy message 21 | let take_dispute_message = Message::new_dispute( 22 | Some(Uuid::new_v4()), 23 | None, 24 | None, 25 | Action::AdminAddSolver, 26 | Some(Payload::TextMessage(npubkey.to_string())), 27 | ); 28 | 29 | send_message_sync( 30 | client, 31 | Some(identity_keys), 32 | trade_keys, 33 | mostro_key, 34 | take_dispute_message, 35 | true, 36 | false, 37 | ) 38 | .await?; 39 | 40 | Ok(()) 41 | } 42 | 43 | pub async fn execute_admin_cancel_dispute( 44 | dispute_id: &Uuid, 45 | identity_keys: &Keys, 46 | trade_keys: &Keys, 47 | mostro_key: PublicKey, 48 | client: &Client, 49 | ) -> Result<()> { 50 | println!( 51 | "Request of cancel dispute {} from mostro pubId {}", 52 | dispute_id, 53 | mostro_key.clone() 54 | ); 55 | // Create takebuy message 56 | let take_dispute_message = 57 | Message::new_dispute(Some(*dispute_id), None, None, Action::AdminCancel, None); 58 | 59 | println!("identity_keys: {:?}", identity_keys.public_key.to_string()); 60 | 61 | send_message_sync( 62 | client, 63 | Some(identity_keys), 64 | trade_keys, 65 | mostro_key, 66 | take_dispute_message, 67 | true, 68 | false, 69 | ) 70 | .await?; 71 | 72 | Ok(()) 73 | } 74 | 75 | pub async fn execute_admin_settle_dispute( 76 | dispute_id: &Uuid, 77 | identity_keys: &Keys, 78 | trade_keys: &Keys, 79 | mostro_key: PublicKey, 80 | client: &Client, 81 | ) -> Result<()> { 82 | println!( 83 | "Request of take dispute {} from mostro pubId {}", 84 | dispute_id, 85 | mostro_key.clone() 86 | ); 87 | // Create takebuy message 88 | let take_dispute_message = 89 | Message::new_dispute(Some(*dispute_id), None, None, Action::AdminSettle, None); 90 | 91 | println!("identity_keys: {:?}", identity_keys.public_key.to_string()); 92 | 93 | send_message_sync( 94 | client, 95 | Some(identity_keys), 96 | trade_keys, 97 | mostro_key, 98 | take_dispute_message, 99 | true, 100 | false, 101 | ) 102 | .await?; 103 | 104 | Ok(()) 105 | } 106 | 107 | pub async fn execute_take_dispute( 108 | dispute_id: &Uuid, 109 | identity_keys: &Keys, 110 | trade_keys: &Keys, 111 | mostro_key: PublicKey, 112 | client: &Client, 113 | ) -> Result<()> { 114 | println!( 115 | "Request of take dispute {} from mostro pubId {}", 116 | dispute_id, 117 | mostro_key.clone() 118 | ); 119 | // Create takebuy message 120 | let take_dispute_message = Message::new_dispute( 121 | Some(*dispute_id), 122 | None, 123 | None, 124 | Action::AdminTakeDispute, 125 | None, 126 | ); 127 | 128 | println!("identity_keys: {:?}", identity_keys.public_key.to_string()); 129 | 130 | send_message_sync( 131 | client, 132 | Some(identity_keys), 133 | trade_keys, 134 | mostro_key, 135 | take_dispute_message, 136 | true, 137 | false, 138 | ) 139 | .await?; 140 | 141 | Ok(()) 142 | } 143 | -------------------------------------------------------------------------------- /src/cli/take_sell.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use lnurl::lightning_address::LightningAddress; 3 | use mostro_core::prelude::*; 4 | 5 | use nostr_sdk::prelude::*; 6 | use std::str::FromStr; 7 | use uuid::Uuid; 8 | 9 | use crate::db::{connect, Order, User}; 10 | use crate::lightning::is_valid_invoice; 11 | use crate::util::send_message_sync; 12 | 13 | #[allow(clippy::too_many_arguments)] 14 | pub async fn execute_take_sell( 15 | order_id: &Uuid, 16 | invoice: &Option, 17 | amount: Option, 18 | identity_keys: &Keys, 19 | trade_keys: &Keys, 20 | trade_index: i64, 21 | mostro_key: PublicKey, 22 | client: &Client, 23 | ) -> Result<()> { 24 | println!( 25 | "Request of take sell order {} from mostro pubId {}", 26 | order_id, 27 | mostro_key.clone() 28 | ); 29 | 30 | let payload = match invoice { 31 | Some(inv) => { 32 | let initial_payload = match LightningAddress::from_str(inv) { 33 | Ok(_) => Payload::PaymentRequest(None, inv.to_string(), None), 34 | Err(_) => match is_valid_invoice(inv) { 35 | Ok(i) => Payload::PaymentRequest(None, i.to_string(), None), 36 | Err(e) => { 37 | println!("{}", e); 38 | Payload::PaymentRequest(None, inv.to_string(), None) // or handle error differently 39 | } 40 | }, 41 | }; 42 | 43 | match amount { 44 | Some(amt) => match initial_payload { 45 | Payload::PaymentRequest(a, b, _) => { 46 | Payload::PaymentRequest(a, b, Some(amt as i64)) 47 | } 48 | payload => payload, 49 | }, 50 | None => initial_payload, 51 | } 52 | } 53 | None => amount 54 | .map(|amt| Payload::Amount(amt.into())) 55 | .unwrap_or(Payload::Amount(0)), 56 | }; 57 | 58 | let request_id = Uuid::new_v4().as_u128() as u64; 59 | // Create takesell message 60 | let take_sell_message = Message::new_order( 61 | Some(*order_id), 62 | Some(request_id), 63 | Some(trade_index), 64 | Action::TakeSell, 65 | Some(payload), 66 | ); 67 | 68 | let dm = send_message_sync( 69 | client, 70 | Some(identity_keys), 71 | trade_keys, 72 | mostro_key, 73 | take_sell_message, 74 | true, 75 | false, 76 | ) 77 | .await?; 78 | let pool = connect().await?; 79 | 80 | let order = dm.iter().find_map(|el| { 81 | let message = el.0.get_inner_message_kind(); 82 | if message.request_id == Some(request_id) { 83 | match message.action { 84 | Action::AddInvoice => { 85 | if let Some(Payload::Order(order)) = message.payload.as_ref() { 86 | println!( 87 | "Please add a lightning invoice with amount of {}", 88 | order.amount 89 | ); 90 | return Some(order.clone()); 91 | } 92 | } 93 | Action::CantDo => { 94 | if let Some(Payload::CantDo(Some(cant_do_reason))) = &message.payload { 95 | match cant_do_reason { 96 | CantDoReason::OutOfRangeFiatAmount | CantDoReason::OutOfRangeSatsAmount => { 97 | println!("Error: Amount is outside the allowed range. Please check the order's min/max limits."); 98 | } 99 | _ => { 100 | println!("Unknown reason: {:?}", message.payload); 101 | } 102 | } 103 | } else { 104 | println!("Unknown reason: {:?}", message.payload); 105 | return None; 106 | } 107 | } 108 | _ => { 109 | println!("Unknown action: {:?}", message.action); 110 | return None; 111 | } 112 | } 113 | } 114 | None 115 | }); 116 | if let Some(o) = order { 117 | if let Ok(order) = Order::new(&pool, o, trade_keys, Some(request_id as i64)).await { 118 | if let Some(order_id) = order.id { 119 | println!("Order {} created", order_id); 120 | } else { 121 | println!("Warning: The newly created order has no ID."); 122 | } 123 | // Update last trade index to be used in next trade 124 | match User::get(&pool).await { 125 | Ok(mut user) => { 126 | user.set_last_trade_index(trade_index); 127 | if let Err(e) = user.save(&pool).await { 128 | println!("Failed to update user: {}", e); 129 | } 130 | } 131 | Err(e) => println!("Failed to get user: {}", e), 132 | } 133 | } 134 | } 135 | 136 | Ok(()) 137 | } 138 | -------------------------------------------------------------------------------- /src/db.rs: -------------------------------------------------------------------------------- 1 | use crate::util::get_mcli_path; 2 | use anyhow::Result; 3 | use mostro_core::prelude::*; 4 | use nip06::FromMnemonic; 5 | use nostr_sdk::prelude::*; 6 | use sqlx::pool::Pool; 7 | use sqlx::Sqlite; 8 | use sqlx::SqlitePool; 9 | use std::fs::File; 10 | use std::path::Path; 11 | 12 | pub async fn connect() -> Result> { 13 | let mcli_dir = get_mcli_path(); 14 | let mcli_db_path = format!("{}/mcli.db", mcli_dir); 15 | let db_url = format!("sqlite://{}", mcli_db_path); 16 | let pool: Pool; 17 | if !Path::exists(Path::new(&mcli_db_path)) { 18 | if let Err(res) = File::create(&mcli_db_path) { 19 | println!("Error in creating db file: {}", res); 20 | return Err(res.into()); 21 | } 22 | pool = SqlitePool::connect(&db_url).await?; 23 | println!("Creating database file with orders table..."); 24 | sqlx::query( 25 | r#" 26 | CREATE TABLE IF NOT EXISTS orders ( 27 | id TEXT PRIMARY KEY, 28 | kind TEXT NOT NULL, 29 | status TEXT NOT NULL, 30 | amount INTEGER NOT NULL, 31 | min_amount INTEGER, 32 | max_amount INTEGER, 33 | fiat_code TEXT NOT NULL, 34 | fiat_amount INTEGER NOT NULL, 35 | payment_method TEXT NOT NULL, 36 | premium INTEGER NOT NULL, 37 | trade_keys TEXT, 38 | counterparty_pubkey TEXT, 39 | is_mine BOOLEAN, 40 | buyer_invoice TEXT, 41 | buyer_token INTEGER, 42 | seller_token INTEGER, 43 | request_id INTEGER, 44 | created_at INTEGER, 45 | expires_at INTEGER 46 | ); 47 | CREATE TABLE IF NOT EXISTS users ( 48 | i0_pubkey char(64) PRIMARY KEY, 49 | mnemonic TEXT, 50 | last_trade_index INTEGER, 51 | created_at INTEGER 52 | ); 53 | "#, 54 | ) 55 | .execute(&pool) 56 | .await?; 57 | 58 | let mnemonic = match Mnemonic::generate(12) { 59 | Ok(m) => m.to_string(), 60 | Err(e) => { 61 | println!("Error generating mnemonic: {}", e); 62 | return Err(e.into()); 63 | } 64 | }; 65 | let user = User::new(mnemonic, &pool).await?; 66 | println!("User created with pubkey: {}", user.i0_pubkey); 67 | } else { 68 | pool = SqlitePool::connect(&db_url).await?; 69 | } 70 | 71 | Ok(pool) 72 | } 73 | 74 | #[derive(Debug, Default, Clone, sqlx::FromRow)] 75 | pub struct User { 76 | /// The user's ID is the identity pubkey 77 | pub i0_pubkey: String, 78 | pub mnemonic: String, 79 | pub last_trade_index: Option, 80 | pub created_at: i64, 81 | } 82 | 83 | impl User { 84 | pub async fn new(mnemonic: String, pool: &SqlitePool) -> Result { 85 | let mut user = User::default(); 86 | let account = NOSTR_REPLACEABLE_EVENT_KIND as u32; 87 | let i0_keys = 88 | Keys::from_mnemonic_advanced(&mnemonic, None, Some(account), Some(0), Some(0))?; 89 | user.i0_pubkey = i0_keys.public_key().to_string(); 90 | user.created_at = chrono::Utc::now().timestamp(); 91 | user.mnemonic = mnemonic; 92 | sqlx::query( 93 | r#" 94 | INSERT INTO users (i0_pubkey, mnemonic, created_at) 95 | VALUES (?, ?, ?) 96 | "#, 97 | ) 98 | .bind(&user.i0_pubkey) 99 | .bind(&user.mnemonic) 100 | .bind(user.created_at) 101 | .execute(pool) 102 | .await?; 103 | 104 | Ok(user) 105 | } 106 | // Chainable setters 107 | pub fn set_mnemonic(&mut self, mnemonic: String) -> &mut Self { 108 | self.mnemonic = mnemonic; 109 | self 110 | } 111 | 112 | pub fn set_last_trade_index(&mut self, last_trade_index: i64) -> &mut Self { 113 | self.last_trade_index = Some(last_trade_index); 114 | self 115 | } 116 | 117 | // Applying changes to the database 118 | pub async fn save(&self, pool: &SqlitePool) -> Result<()> { 119 | sqlx::query( 120 | r#" 121 | UPDATE users 122 | SET mnemonic = ?, last_trade_index = ? 123 | WHERE i0_pubkey = ? 124 | "#, 125 | ) 126 | .bind(&self.mnemonic) 127 | .bind(self.last_trade_index) 128 | .bind(&self.i0_pubkey) 129 | .execute(pool) 130 | .await?; 131 | 132 | println!( 133 | "User with i0 pubkey {} updated in the database.", 134 | self.i0_pubkey 135 | ); 136 | 137 | Ok(()) 138 | } 139 | 140 | pub async fn get(pool: &SqlitePool) -> Result { 141 | let user = sqlx::query_as::<_, User>( 142 | r#" 143 | SELECT i0_pubkey, mnemonic, last_trade_index, created_at 144 | FROM users 145 | LIMIT 1 146 | "#, 147 | ) 148 | .fetch_one(pool) 149 | .await?; 150 | 151 | Ok(user) 152 | } 153 | 154 | pub async fn get_last_trade_index(pool: SqlitePool) -> Result { 155 | let user = User::get(&pool).await?; 156 | match user.last_trade_index { 157 | Some(index) => Ok(index), 158 | None => Ok(0), 159 | } 160 | } 161 | 162 | pub async fn get_next_trade_index(pool: SqlitePool) -> Result { 163 | let last_trade_index = User::get_last_trade_index(pool).await?; 164 | Ok(last_trade_index + 1) 165 | } 166 | 167 | pub async fn get_identity_keys(pool: &SqlitePool) -> Result { 168 | let user = User::get(pool).await?; 169 | let account = NOSTR_REPLACEABLE_EVENT_KIND as u32; 170 | let keys = 171 | Keys::from_mnemonic_advanced(&user.mnemonic, None, Some(account), Some(0), Some(0))?; 172 | 173 | Ok(keys) 174 | } 175 | 176 | pub async fn get_next_trade_keys(pool: &SqlitePool) -> Result<(Keys, i64)> { 177 | let trade_index = User::get_next_trade_index(pool.clone()).await?; 178 | let trade_keys = User::get_trade_keys(pool, trade_index).await?; 179 | 180 | Ok((trade_keys, trade_index)) 181 | } 182 | 183 | pub async fn get_trade_keys(pool: &SqlitePool, index: i64) -> Result { 184 | if index < 0 { 185 | return Err(anyhow::anyhow!("Trade index cannot be negative")); 186 | } 187 | let user = User::get(pool).await?; 188 | let account = NOSTR_REPLACEABLE_EVENT_KIND as u32; 189 | let keys = Keys::from_mnemonic_advanced( 190 | &user.mnemonic, 191 | None, 192 | Some(account), 193 | Some(0), 194 | Some(index as u32), 195 | )?; 196 | 197 | Ok(keys) 198 | } 199 | } 200 | 201 | #[derive(Debug, Default, Clone, sqlx::FromRow)] 202 | pub struct Order { 203 | pub id: Option, 204 | pub kind: Option, 205 | pub status: Option, 206 | pub amount: i64, 207 | pub fiat_code: String, 208 | pub min_amount: Option, 209 | pub max_amount: Option, 210 | pub fiat_amount: i64, 211 | pub payment_method: String, 212 | pub premium: i64, 213 | pub trade_keys: Option, 214 | pub counterparty_pubkey: Option, 215 | pub is_mine: Option, 216 | pub buyer_invoice: Option, 217 | pub buyer_token: Option, 218 | pub seller_token: Option, 219 | pub request_id: Option, 220 | pub created_at: Option, 221 | pub expires_at: Option, 222 | } 223 | 224 | impl Order { 225 | pub async fn new( 226 | pool: &SqlitePool, 227 | order: SmallOrder, 228 | trade_keys: &Keys, 229 | request_id: Option, 230 | ) -> Result { 231 | let trade_keys_hex = trade_keys.secret_key().to_secret_hex(); 232 | let id = match order.id { 233 | Some(id) => id.to_string(), 234 | None => uuid::Uuid::new_v4().to_string(), 235 | }; 236 | let order = Order { 237 | id: Some(id), 238 | kind: order.kind.as_ref().map(|k| k.to_string()), 239 | status: order.status.as_ref().map(|s| s.to_string()), 240 | amount: order.amount, 241 | fiat_code: order.fiat_code, 242 | min_amount: order.min_amount, 243 | max_amount: order.max_amount, 244 | fiat_amount: order.fiat_amount, 245 | payment_method: order.payment_method, 246 | premium: order.premium, 247 | trade_keys: Some(trade_keys_hex), 248 | counterparty_pubkey: None, 249 | is_mine: Some(true), 250 | buyer_invoice: None, 251 | buyer_token: None, 252 | seller_token: None, 253 | request_id, 254 | created_at: Some(chrono::Utc::now().timestamp()), 255 | expires_at: None, 256 | }; 257 | 258 | sqlx::query( 259 | r#" 260 | INSERT INTO orders (id, kind, status, amount, min_amount, max_amount, 261 | fiat_code, fiat_amount, payment_method, premium, trade_keys, 262 | counterparty_pubkey, is_mine, buyer_invoice, buyer_token, seller_token, 263 | request_id, created_at, expires_at) 264 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 265 | "#, 266 | ) 267 | .bind(&order.id) 268 | .bind(&order.kind) 269 | .bind(&order.status) 270 | .bind(order.amount) 271 | .bind(order.min_amount) 272 | .bind(order.max_amount) 273 | .bind(&order.fiat_code) 274 | .bind(order.fiat_amount) 275 | .bind(&order.payment_method) 276 | .bind(order.premium) 277 | .bind(&order.trade_keys) 278 | .bind(&order.counterparty_pubkey) 279 | .bind(order.is_mine) 280 | .bind(&order.buyer_invoice) 281 | .bind(order.buyer_token) 282 | .bind(order.seller_token) 283 | .bind(order.request_id) 284 | .bind(order.created_at) 285 | .bind(order.expires_at) 286 | .execute(pool) 287 | .await?; 288 | 289 | Ok(order) 290 | } 291 | 292 | // Setters encadenables 293 | pub fn set_kind(&mut self, kind: String) -> &mut Self { 294 | self.kind = Some(kind); 295 | self 296 | } 297 | 298 | pub fn set_status(&mut self, status: String) -> &mut Self { 299 | self.status = Some(status); 300 | self 301 | } 302 | 303 | pub fn set_amount(&mut self, amount: i64) -> &mut Self { 304 | self.amount = amount; 305 | self 306 | } 307 | 308 | pub fn set_fiat_code(&mut self, fiat_code: String) -> &mut Self { 309 | self.fiat_code = fiat_code; 310 | self 311 | } 312 | 313 | pub fn set_min_amount(&mut self, min_amount: i64) -> &mut Self { 314 | self.min_amount = Some(min_amount); 315 | self 316 | } 317 | 318 | pub fn set_max_amount(&mut self, max_amount: i64) -> &mut Self { 319 | self.max_amount = Some(max_amount); 320 | self 321 | } 322 | 323 | pub fn set_fiat_amount(&mut self, fiat_amount: i64) -> &mut Self { 324 | self.fiat_amount = fiat_amount; 325 | self 326 | } 327 | 328 | pub fn set_payment_method(&mut self, payment_method: String) -> &mut Self { 329 | self.payment_method = payment_method; 330 | self 331 | } 332 | 333 | pub fn set_premium(&mut self, premium: i64) -> &mut Self { 334 | self.premium = premium; 335 | self 336 | } 337 | 338 | pub fn set_counterparty_pubkey(&mut self, counterparty_pubkey: String) -> &mut Self { 339 | self.counterparty_pubkey = Some(counterparty_pubkey); 340 | self 341 | } 342 | 343 | pub fn set_trade_keys(&mut self, trade_keys: String) -> &mut Self { 344 | self.trade_keys = Some(trade_keys); 345 | self 346 | } 347 | 348 | pub fn set_is_mine(&mut self, is_mine: bool) -> &mut Self { 349 | self.is_mine = Some(is_mine); 350 | self 351 | } 352 | 353 | // Applying changes to the database 354 | pub async fn save(&self, pool: &SqlitePool) -> Result<()> { 355 | // Validation if an identity document is present 356 | if let Some(ref id) = self.id { 357 | sqlx::query( 358 | r#" 359 | UPDATE orders 360 | SET kind = ?, status = ?, amount = ?, fiat_code = ?, min_amount = ?, max_amount = ?, 361 | fiat_amount = ?, payment_method = ?, premium = ?, trade_keys = ?, counterparty_pubkey = ?, 362 | is_mine = ?, buyer_invoice = ?, created_at = ?, expires_at = ?, buyer_token = ?, 363 | seller_token = ? 364 | WHERE id = ? 365 | "#, 366 | ) 367 | .bind(&self.kind) 368 | .bind(&self.status) 369 | .bind(self.amount) 370 | .bind(&self.fiat_code) 371 | .bind(self.min_amount) 372 | .bind(self.max_amount) 373 | .bind(self.fiat_amount) 374 | .bind(&self.payment_method) 375 | .bind(self.premium) 376 | .bind(&self.trade_keys) 377 | .bind(&self.counterparty_pubkey) 378 | .bind(self.is_mine) 379 | .bind(&self.buyer_invoice) 380 | .bind(self.created_at) 381 | .bind(self.expires_at) 382 | .bind(self.buyer_token) 383 | .bind(self.seller_token) 384 | .bind(id) 385 | .execute(pool) 386 | .await?; 387 | 388 | println!("Order with id {} updated in the database.", id); 389 | } else { 390 | return Err(anyhow::anyhow!("Order must have an ID to be updated.")); 391 | } 392 | 393 | Ok(()) 394 | } 395 | 396 | pub async fn save_new_id( 397 | pool: &SqlitePool, 398 | id: String, 399 | new_id: String, 400 | ) -> anyhow::Result { 401 | let rows_affected = sqlx::query( 402 | r#" 403 | UPDATE orders 404 | SET id = ? 405 | WHERE id = ? 406 | "#, 407 | ) 408 | .bind(&new_id) 409 | .bind(&id) 410 | .execute(pool) 411 | .await? 412 | .rows_affected(); 413 | 414 | Ok(rows_affected > 0) 415 | } 416 | 417 | pub async fn get_by_id(pool: &SqlitePool, id: &str) -> Result { 418 | let order = sqlx::query_as::<_, Order>( 419 | r#" 420 | SELECT * FROM orders WHERE id = ? 421 | LIMIT 1 422 | "#, 423 | ) 424 | .bind(id) 425 | .fetch_one(pool) 426 | .await?; 427 | 428 | if order.id.is_none() { 429 | return Err(anyhow::anyhow!("Order not found")); 430 | } 431 | 432 | Ok(order) 433 | } 434 | 435 | pub async fn get_all(pool: &SqlitePool) -> Result> { 436 | let orders = sqlx::query_as::<_, Order>(r#"SELECT * FROM orders"#) 437 | .fetch_all(pool) 438 | .await?; 439 | Ok(orders) 440 | } 441 | 442 | pub async fn delete_by_id(pool: &SqlitePool, id: &str) -> Result { 443 | let rows_affected = sqlx::query( 444 | r#" 445 | DELETE FROM orders 446 | WHERE id = ? 447 | "#, 448 | ) 449 | .bind(id) 450 | .execute(pool) 451 | .await? 452 | .rows_affected(); 453 | 454 | Ok(rows_affected > 0) 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[derive(Debug)] 4 | pub enum MostroError { 5 | ParsingInvoiceError, 6 | ParsingNumberError, 7 | InvoiceExpiredError, 8 | MinExpirationTimeError, 9 | MinAmountError, 10 | } 11 | 12 | impl std::error::Error for MostroError {} 13 | 14 | impl fmt::Display for MostroError { 15 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 16 | match self { 17 | MostroError::ParsingInvoiceError => write!(f, "Incorrect invoice"), 18 | MostroError::ParsingNumberError => write!(f, "Error parsing the number"), 19 | MostroError::InvoiceExpiredError => write!(f, "Invoice has expired"), 20 | MostroError::MinExpirationTimeError => write!(f, "Minimal expiration time on invoice"), 21 | MostroError::MinAmountError => write!(f, "Minimal payment amount"), 22 | } 23 | } 24 | } 25 | 26 | impl From for MostroError { 27 | fn from(_: lightning_invoice::Bolt11ParseError) -> Self { 28 | MostroError::ParsingInvoiceError 29 | } 30 | } 31 | 32 | impl From for MostroError { 33 | fn from(_: lightning_invoice::ParseOrSemanticError) -> Self { 34 | MostroError::ParsingInvoiceError 35 | } 36 | } 37 | 38 | impl From for MostroError { 39 | fn from(_: std::num::ParseIntError) -> Self { 40 | MostroError::ParsingNumberError 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/fiat.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::collections::HashMap; 3 | 4 | pub type FiatNames = HashMap; 5 | pub type FiatList = Vec<(String, String)>; 6 | 7 | #[derive(Debug, Serialize, Deserialize)] 8 | pub struct FiatNamesValue { 9 | #[serde(rename = "symbol")] 10 | symbol: String, 11 | 12 | #[serde(rename = "name")] 13 | name: String, 14 | 15 | #[serde(rename = "symbol_native")] 16 | symbol_native: String, 17 | 18 | #[serde(rename = "decimal_digits")] 19 | decimal_digits: i64, 20 | 21 | #[serde(rename = "rounding")] 22 | rounding: f64, 23 | 24 | #[serde(rename = "code")] 25 | code: String, 26 | 27 | #[serde(rename = "emoji")] 28 | emoji: String, 29 | 30 | #[serde(rename = "name_plural")] 31 | name_plural: String, 32 | 33 | #[serde(rename = "price")] 34 | price: Option, 35 | 36 | #[serde(rename = "locale")] 37 | locale: Option, 38 | } 39 | 40 | pub fn check_currency_ticker(currency: String) -> Option { 41 | let upper = currency.to_uppercase(); 42 | let mut selectedcurrency: Option = None; 43 | let mut description = String::new(); 44 | 45 | let list = load_fiat_values(); 46 | 47 | for curr in list.iter() { 48 | if curr.0 == upper { 49 | selectedcurrency = Some(curr.0.to_owned()); 50 | description = curr.1.to_owned(); 51 | } 52 | } 53 | 54 | match selectedcurrency.clone() { 55 | Some(s) => println!("You have selected all offers of {} - {}", s, description), 56 | None => println!("Mmmmhhh you shouldn't be arrived here...something bad!"), 57 | } 58 | 59 | selectedcurrency 60 | } 61 | 62 | pub fn load_fiat_values() -> FiatList { 63 | let fiat_names = r#" 64 | { 65 | "AED": { 66 | "symbol": "AED", 67 | "name": "United Arab Emirates Dirham", 68 | "symbol_native": "د.إ.‏", 69 | "decimal_digits": 2, 70 | "rounding": 0, 71 | "code": "AED", 72 | "emoji": "🇦🇪", 73 | "name_plural": "UAE dirhams", 74 | "price": true 75 | }, 76 | "AFN": { 77 | "symbol": "Af", 78 | "name": "Afghan Afghani", 79 | "symbol_native": "؋", 80 | "decimal_digits": 0, 81 | "rounding": 0, 82 | "code": "AFN", 83 | "emoji": "", 84 | "name_plural": "Afghan Afghanis" 85 | }, 86 | "ALL": { 87 | "symbol": "ALL", 88 | "name": "Albanian Lek", 89 | "symbol_native": "Lek", 90 | "decimal_digits": 0, 91 | "rounding": 0, 92 | "code": "ALL", 93 | "emoji": "", 94 | "name_plural": "Albanian lekë" 95 | }, 96 | "AMD": { 97 | "symbol": "AMD", 98 | "name": "Armenian Dram", 99 | "symbol_native": "դր.", 100 | "decimal_digits": 0, 101 | "rounding": 0, 102 | "code": "AMD", 103 | "emoji": "", 104 | "name_plural": "Armenian drams" 105 | }, 106 | "ANG": { 107 | "symbol": "ANG", 108 | "name": "Netherlands Antillean Guilder", 109 | "symbol_native": "NAƒ", 110 | "decimal_digits": 2, 111 | "rounding": 0, 112 | "code": "ANG", 113 | "emoji": "🇧🇶", 114 | "name_plural": "ANG", 115 | "price": true 116 | }, 117 | "AOA": { 118 | "symbol": "AOA", 119 | "name": "Angolan Kwanza", 120 | "symbol_native": "Kz", 121 | "decimal_digits": 2, 122 | "rounding": 0, 123 | "code": "AOA", 124 | "emoji": "🇦🇴", 125 | "name_plural": "AOA", 126 | "price": true 127 | }, 128 | "ARS": { 129 | "symbol": "AR$", 130 | "name": "Peso argentino", 131 | "symbol_native": "$", 132 | "decimal_digits": 2, 133 | "rounding": 0, 134 | "code": "ARS", 135 | "emoji": "🇦🇷", 136 | "name_plural": "Pesos", 137 | "price": true, 138 | "locale": "es-AR" 139 | }, 140 | "AUD": { 141 | "symbol": "AU$", 142 | "name": "Australian Dollar", 143 | "symbol_native": "$", 144 | "decimal_digits": 2, 145 | "rounding": 0, 146 | "code": "AUD", 147 | "emoji": "🇦🇺", 148 | "name_plural": "Australian dollars", 149 | "price": true, 150 | "locale": "en-AU" 151 | }, 152 | "AZN": { 153 | "symbol": "man.", 154 | "name": "Azerbaijani Manat", 155 | "symbol_native": "ман.", 156 | "decimal_digits": 2, 157 | "rounding": 0, 158 | "code": "AZN", 159 | "emoji": "🇦🇿", 160 | "name_plural": "Azerbaijani manats", 161 | "price": true 162 | }, 163 | "BAM": { 164 | "symbol": "KM", 165 | "name": "Bosnia-Herzegovina Convertible Mark", 166 | "symbol_native": "KM", 167 | "decimal_digits": 2, 168 | "rounding": 0, 169 | "code": "BAM", 170 | "emoji": "", 171 | "name_plural": "Bosnia-Herzegovina convertible marks" 172 | }, 173 | "BDT": { 174 | "symbol": "Tk", 175 | "name": "Bangladeshi Taka", 176 | "symbol_native": "৳", 177 | "decimal_digits": 2, 178 | "rounding": 0, 179 | "code": "BDT", 180 | "emoji": "🇧🇩", 181 | "name_plural": "Bangladeshi takas", 182 | "price": true 183 | }, 184 | "BGN": { 185 | "symbol": "BGN", 186 | "name": "Bulgarian Lev", 187 | "symbol_native": "лв.", 188 | "decimal_digits": 2, 189 | "rounding": 0, 190 | "code": "BGN", 191 | "emoji": "", 192 | "name_plural": "Bulgarian leva" 193 | }, 194 | "BHD": { 195 | "symbol": "BHD", 196 | "name": "Bahraini Dinar", 197 | "symbol_native": "د.ب.‏", 198 | "decimal_digits": 3, 199 | "rounding": 0, 200 | "code": "BHD", 201 | "emoji": "🇧🇭", 202 | "name_plural": "Bahraini dinars", 203 | "price": true 204 | }, 205 | "BIF": { 206 | "symbol": "FBu", 207 | "name": "Burundian Franc", 208 | "symbol_native": "FBu", 209 | "decimal_digits": 0, 210 | "rounding": 0, 211 | "code": "BIF", 212 | "emoji": "", 213 | "name_plural": "Burundian francs" 214 | }, 215 | "BMD": { 216 | "symbol": "BMD", 217 | "name": "Bermudan Dollar", 218 | "symbol_native": "$", 219 | "decimal_digits": 2, 220 | "rounding": 0, 221 | "code": "BMD", 222 | "emoji": "🇧🇲", 223 | "name_plural": "Bermudan Dollar", 224 | "price": true 225 | }, 226 | "BND": { 227 | "symbol": "BN$", 228 | "name": "Brunei Dollar", 229 | "symbol_native": "$", 230 | "decimal_digits": 2, 231 | "rounding": 0, 232 | "code": "BND", 233 | "emoji": "", 234 | "name_plural": "Brunei dollars" 235 | }, 236 | "BOB": { 237 | "symbol": "Bs", 238 | "name": "Boliviano", 239 | "symbol_native": "Bs", 240 | "decimal_digits": 2, 241 | "rounding": 0, 242 | "code": "BOB", 243 | "emoji": "🇧🇴", 244 | "name_plural": "Bolivianos", 245 | "price": true, 246 | "locale": "es-BO" 247 | }, 248 | "BRL": { 249 | "symbol": "R$", 250 | "name": "Brazilian Real", 251 | "symbol_native": "R$", 252 | "decimal_digits": 2, 253 | "rounding": 0, 254 | "code": "BRL", 255 | "emoji": "🇧🇷", 256 | "name_plural": "Brazilian reals", 257 | "price": true, 258 | "locale": "pt-BR" 259 | }, 260 | "BWP": { 261 | "symbol": "BWP", 262 | "name": "Botswanan Pula", 263 | "symbol_native": "P", 264 | "decimal_digits": 2, 265 | "rounding": 0, 266 | "code": "BWP", 267 | "emoji": "", 268 | "name_plural": "Botswanan pulas", 269 | "price": true 270 | }, 271 | "BYN": { 272 | "symbol": "Br", 273 | "name": "Belarusian Ruble", 274 | "symbol_native": "руб.", 275 | "decimal_digits": 2, 276 | "rounding": 0, 277 | "code": "BYN", 278 | "emoji": "🇧🇾", 279 | "name_plural": "Belarusian rubles", 280 | "price": true 281 | }, 282 | "BZD": { 283 | "symbol": "BZ$", 284 | "name": "Belize Dollar", 285 | "symbol_native": "$", 286 | "decimal_digits": 2, 287 | "rounding": 0, 288 | "code": "BZD", 289 | "emoji": "", 290 | "name_plural": "Belize dollars" 291 | }, 292 | "CAD": { 293 | "symbol": "CA$", 294 | "name": "Canadian Dollar", 295 | "symbol_native": "$", 296 | "decimal_digits": 2, 297 | "rounding": 0, 298 | "code": "CAD", 299 | "emoji": "🇨🇦", 300 | "name_plural": "Canadian dollars", 301 | "price": true, 302 | "locale": "en-CA" 303 | }, 304 | "CDF": { 305 | "symbol": "CDF", 306 | "name": "Congolese Franc", 307 | "symbol_native": "FrCD", 308 | "decimal_digits": 2, 309 | "rounding": 0, 310 | "code": "CDF", 311 | "emoji": "", 312 | "name_plural": "Congolese francs", 313 | "price": true 314 | }, 315 | "CHF": { 316 | "symbol": "CHF", 317 | "name": "Swiss Franc", 318 | "symbol_native": "CHF", 319 | "decimal_digits": 2, 320 | "rounding": 0.05, 321 | "code": "CHF", 322 | "emoji": "🇨🇭", 323 | "name_plural": "Swiss francs", 324 | "price": true 325 | }, 326 | "CLP": { 327 | "symbol": "CL$", 328 | "name": "Peso chileno", 329 | "symbol_native": "$", 330 | "decimal_digits": 0, 331 | "rounding": 0, 332 | "code": "CLP", 333 | "emoji": "🇨🇱", 334 | "name_plural": "Pesos", 335 | "price": true, 336 | "locale": "es-CL" 337 | }, 338 | "CNY": { 339 | "symbol": "CN¥", 340 | "name": "Chinese Yuan", 341 | "symbol_native": "CN¥", 342 | "decimal_digits": 2, 343 | "rounding": 0, 344 | "code": "CNY", 345 | "emoji": "🇨🇳", 346 | "name_plural": "Chinese yuan", 347 | "price": true 348 | }, 349 | "COP": { 350 | "symbol": "CO$", 351 | "name": "Peso colombiano", 352 | "symbol_native": "$", 353 | "decimal_digits": 0, 354 | "rounding": 0, 355 | "code": "COP", 356 | "emoji": "🇨🇴", 357 | "name_plural": "Pesos", 358 | "price": true, 359 | "locale": "es-CO" 360 | }, 361 | "CRC": { 362 | "symbol": "₡", 363 | "name": "Colón", 364 | "symbol_native": "₡", 365 | "decimal_digits": 0, 366 | "rounding": 0, 367 | "code": "CRC", 368 | "emoji": "🇨🇷", 369 | "name_plural": "Colones", 370 | "price": true, 371 | "locale": "es-CR" 372 | }, 373 | "CUP": { 374 | "symbol": "CU$", 375 | "name": "Peso cubano", 376 | "symbol_native": "$", 377 | "decimal_digits": 2, 378 | "rounding": 0, 379 | "code": "CUP", 380 | "emoji": "🇨🇺", 381 | "name_plural": "Pesos", 382 | "price": true, 383 | "locale": "es-AR" 384 | }, 385 | "CVE": { 386 | "symbol": "CV$", 387 | "name": "Cape Verdean Escudo", 388 | "symbol_native": "CV$", 389 | "decimal_digits": 2, 390 | "rounding": 0, 391 | "code": "CVE", 392 | "emoji": "", 393 | "name_plural": "Cape Verdean escudos" 394 | }, 395 | "CZK": { 396 | "symbol": "Kč", 397 | "name": "Czech Republic Koruna", 398 | "symbol_native": "Kč", 399 | "decimal_digits": 2, 400 | "rounding": 0, 401 | "code": "CZK", 402 | "emoji": "🇨🇿", 403 | "name_plural": "Czech Republic korunas", 404 | "price": true 405 | }, 406 | "DJF": { 407 | "symbol": "Fdj", 408 | "name": "Djiboutian Franc", 409 | "symbol_native": "Fdj", 410 | "decimal_digits": 0, 411 | "rounding": 0, 412 | "code": "DJF", 413 | "emoji": "", 414 | "name_plural": "Djiboutian francs" 415 | }, 416 | "DKK": { 417 | "symbol": "Dkr", 418 | "name": "Danish Krone", 419 | "symbol_native": "kr", 420 | "decimal_digits": 2, 421 | "rounding": 0, 422 | "code": "DKK", 423 | "emoji": "🇩🇰", 424 | "name_plural": "Danish kroner", 425 | "price": true 426 | }, 427 | "DOP": { 428 | "symbol": "RD$", 429 | "name": "Peso dominicano", 430 | "symbol_native": "RD$", 431 | "decimal_digits": 2, 432 | "rounding": 0, 433 | "code": "DOP", 434 | "emoji": "🇩🇴", 435 | "name_plural": "Pesos", 436 | "price": true, 437 | "locale": "es-DO" 438 | }, 439 | "DZD": { 440 | "symbol": "DA", 441 | "name": "Algerian Dinar", 442 | "symbol_native": "د.ج.‏", 443 | "decimal_digits": 2, 444 | "rounding": 0, 445 | "code": "DZD", 446 | "emoji": "🇩🇿", 447 | "name_plural": "Algerian dinars", 448 | "price": true 449 | }, 450 | "EEK": { 451 | "symbol": "Ekr", 452 | "name": "Estonian Kroon", 453 | "symbol_native": "kr", 454 | "decimal_digits": 2, 455 | "rounding": 0, 456 | "code": "EEK", 457 | "emoji": "", 458 | "name_plural": "Estonian kroons" 459 | }, 460 | "EGP": { 461 | "symbol": "EGP", 462 | "name": "Egyptian Pound", 463 | "symbol_native": "ج.م.‏", 464 | "decimal_digits": 2, 465 | "rounding": 0, 466 | "code": "EGP", 467 | "emoji": "🇪🇬", 468 | "name_plural": "Egyptian pounds", 469 | "price": true 470 | }, 471 | "ERN": { 472 | "symbol": "Nfk", 473 | "name": "Eritrean Nakfa", 474 | "symbol_native": "Nfk", 475 | "decimal_digits": 2, 476 | "rounding": 0, 477 | "code": "ERN", 478 | "emoji": "", 479 | "name_plural": "Eritrean nakfas" 480 | }, 481 | "ETB": { 482 | "symbol": "Br", 483 | "name": "Ethiopian Birr", 484 | "symbol_native": "Br", 485 | "decimal_digits": 2, 486 | "rounding": 0, 487 | "code": "ETB", 488 | "emoji": "🇪🇹", 489 | "name_plural": "Ethiopian birrs", 490 | "price": true 491 | }, 492 | "EUR": { 493 | "symbol": "€", 494 | "name": "Euro", 495 | "symbol_native": "€", 496 | "decimal_digits": 2, 497 | "rounding": 0, 498 | "code": "EUR", 499 | "emoji": "🇪🇺", 500 | "name_plural": "euros", 501 | "price": true, 502 | "locale": "de-DE" 503 | }, 504 | "GBP": { 505 | "symbol": "£", 506 | "name": "British Pound Sterling", 507 | "symbol_native": "£", 508 | "decimal_digits": 2, 509 | "rounding": 0, 510 | "code": "GBP", 511 | "emoji": "🇬🇧", 512 | "name_plural": "British pounds sterling", 513 | "price": true 514 | }, 515 | "GEL": { 516 | "symbol": "GEL", 517 | "name": "Georgian Lari", 518 | "symbol_native": "GEL", 519 | "decimal_digits": 2, 520 | "rounding": 0, 521 | "code": "GEL", 522 | "emoji": "🇬🇪", 523 | "name_plural": "Georgian laris", 524 | "price": true 525 | }, 526 | "GHS": { 527 | "symbol": "GH₵", 528 | "name": "Ghanaian Cedi", 529 | "symbol_native": "GH₵", 530 | "decimal_digits": 2, 531 | "rounding": 0, 532 | "code": "GHS", 533 | "emoji": "🇬🇭", 534 | "name_plural": "Ghanaian cedis", 535 | "price": true 536 | }, 537 | "GNF": { 538 | "symbol": "FG", 539 | "name": "Guinean Franc", 540 | "symbol_native": "FG", 541 | "decimal_digits": 0, 542 | "rounding": 0, 543 | "code": "GNF", 544 | "emoji": "", 545 | "name_plural": "Guinean francs" 546 | }, 547 | "GTQ": { 548 | "symbol": "GTQ", 549 | "name": "Quetzal", 550 | "symbol_native": "Q", 551 | "decimal_digits": 2, 552 | "rounding": 0, 553 | "code": "GTQ", 554 | "emoji": "🇬🇹", 555 | "name_plural": "Quetzales", 556 | "price": true 557 | }, 558 | "HKD": { 559 | "symbol": "HK$", 560 | "name": "Hong Kong Dollar", 561 | "symbol_native": "$", 562 | "decimal_digits": 2, 563 | "rounding": 0, 564 | "code": "HKD", 565 | "emoji": "🇭🇰", 566 | "name_plural": "Hong Kong dollars", 567 | "price": true 568 | }, 569 | "HNL": { 570 | "symbol": "HNL", 571 | "name": "Lempira", 572 | "symbol_native": "L", 573 | "decimal_digits": 2, 574 | "rounding": 0, 575 | "code": "HNL", 576 | "emoji": "🇭🇳", 577 | "name_plural": "Lempiras", 578 | "price": true 579 | }, 580 | "HRK": { 581 | "symbol": "kn", 582 | "name": "Croatian Kuna", 583 | "symbol_native": "kn", 584 | "decimal_digits": 2, 585 | "rounding": 0, 586 | "code": "HRK", 587 | "emoji": "", 588 | "name_plural": "Croatian kunas" 589 | }, 590 | "HUF": { 591 | "symbol": "Ft", 592 | "name": "Hungarian Forint", 593 | "symbol_native": "Ft", 594 | "decimal_digits": 0, 595 | "rounding": 0, 596 | "code": "HUF", 597 | "emoji": "🇭🇺", 598 | "name_plural": "Hungarian forints", 599 | "price": true 600 | }, 601 | "IDR": { 602 | "symbol": "Rp", 603 | "name": "Indonesian Rupiah", 604 | "symbol_native": "Rp", 605 | "decimal_digits": 0, 606 | "rounding": 0, 607 | "code": "IDR", 608 | "emoji": "🇮🇩", 609 | "name_plural": "Indonesian rupiahs", 610 | "price": true 611 | }, 612 | "ILS": { 613 | "symbol": "₪", 614 | "name": "Israeli New Sheqel", 615 | "symbol_native": "₪", 616 | "decimal_digits": 2, 617 | "rounding": 0, 618 | "code": "ILS", 619 | "emoji": "🇮🇱", 620 | "name_plural": "Israeli new sheqels", 621 | "price": true 622 | }, 623 | "INR": { 624 | "symbol": "Rs", 625 | "name": "Indian Rupee", 626 | "symbol_native": "টকা", 627 | "decimal_digits": 2, 628 | "rounding": 0, 629 | "code": "INR", 630 | "emoji": "🇮🇳", 631 | "name_plural": "Indian rupees", 632 | "price": true 633 | }, 634 | "IQD": { 635 | "symbol": "IQD", 636 | "name": "Iraqi Dinar", 637 | "symbol_native": "د.ع.‏", 638 | "decimal_digits": 0, 639 | "rounding": 0, 640 | "code": "IQD", 641 | "emoji": "", 642 | "name_plural": "Iraqi dinars" 643 | }, 644 | "IRR": { 645 | "symbol": "IRR", 646 | "name": "Iranian Rial", 647 | "symbol_native": "﷼", 648 | "decimal_digits": 0, 649 | "rounding": 0, 650 | "code": "IRR", 651 | "emoji": "", 652 | "name_plural": "Iranian rials" 653 | }, 654 | "ISK": { 655 | "symbol": "Ikr", 656 | "name": "Icelandic Króna", 657 | "symbol_native": "kr", 658 | "decimal_digits": 0, 659 | "rounding": 0, 660 | "code": "ISK", 661 | "emoji": "", 662 | "name_plural": "Icelandic krónur" 663 | }, 664 | "JMD": { 665 | "symbol": "J$", 666 | "name": "Jamaican Dollar", 667 | "symbol_native": "$", 668 | "decimal_digits": 2, 669 | "rounding": 0, 670 | "code": "JMD", 671 | "emoji": "🇯🇲", 672 | "name_plural": "Jamaican dollars", 673 | "price": true 674 | }, 675 | "JOD": { 676 | "symbol": "JD", 677 | "name": "Jordanian Dinar", 678 | "symbol_native": "د.أ.‏", 679 | "decimal_digits": 3, 680 | "rounding": 0, 681 | "code": "JOD", 682 | "emoji": "🇯🇴", 683 | "name_plural": "Jordanian dinars", 684 | "price": true 685 | }, 686 | "JPY": { 687 | "symbol": "¥", 688 | "name": "Japanese Yen", 689 | "symbol_native": "¥", 690 | "decimal_digits": 0, 691 | "rounding": 0, 692 | "code": "JPY", 693 | "emoji": "🇯🇵", 694 | "name_plural": "Japanese yen", 695 | "price": true 696 | }, 697 | "KES": { 698 | "symbol": "Ksh", 699 | "name": "Kenyan Shilling", 700 | "symbol_native": "Ksh", 701 | "decimal_digits": 2, 702 | "rounding": 0, 703 | "code": "KES", 704 | "emoji": "🇰🇪", 705 | "name_plural": "Kenyan shillings", 706 | "price": true 707 | }, 708 | "KGS": { 709 | "symbol": "KGS", 710 | "name": "Kyrgystani Som", 711 | "symbol_native": "KGS", 712 | "decimal_digits": 2, 713 | "rounding": 0, 714 | "code": "KGS", 715 | "emoji": "🇰🇬", 716 | "name_plural": "Kyrgystani Som", 717 | "price": true 718 | }, 719 | "KHR": { 720 | "symbol": "KHR", 721 | "name": "Cambodian Riel", 722 | "symbol_native": "៛", 723 | "decimal_digits": 2, 724 | "rounding": 0, 725 | "code": "KHR", 726 | "emoji": "", 727 | "name_plural": "Cambodian riels" 728 | }, 729 | "KMF": { 730 | "symbol": "CF", 731 | "name": "Comorian Franc", 732 | "symbol_native": "FC", 733 | "decimal_digits": 0, 734 | "rounding": 0, 735 | "code": "KMF", 736 | "emoji": "", 737 | "name_plural": "Comorian francs" 738 | }, 739 | "KRW": { 740 | "symbol": "₩", 741 | "name": "South Korean Won", 742 | "symbol_native": "₩", 743 | "decimal_digits": 0, 744 | "rounding": 0, 745 | "code": "KRW", 746 | "emoji": "🇰🇷", 747 | "name_plural": "South Korean won", 748 | "price": true 749 | }, 750 | "KWD": { 751 | "symbol": "KD", 752 | "name": "Kuwaiti Dinar", 753 | "symbol_native": "د.ك.‏", 754 | "decimal_digits": 3, 755 | "rounding": 0, 756 | "code": "KWD", 757 | "emoji": "", 758 | "name_plural": "Kuwaiti dinars" 759 | }, 760 | "KZT": { 761 | "symbol": "KZT", 762 | "name": "Kazakhstani Tenge", 763 | "symbol_native": "тңг.", 764 | "decimal_digits": 2, 765 | "rounding": 0, 766 | "code": "KZT", 767 | "emoji": "🇰🇿", 768 | "name_plural": "Kazakhstani tenges", 769 | "price": true 770 | }, 771 | "LBP": { 772 | "symbol": "L.L.", 773 | "name": "Lebanese Pound", 774 | "symbol_native": "ل.ل.‏", 775 | "decimal_digits": 0, 776 | "rounding": 0, 777 | "code": "LBP", 778 | "emoji": "🇱🇧", 779 | "name_plural": "Lebanese pounds", 780 | "price": true 781 | }, 782 | "LKR": { 783 | "symbol": "SLRs", 784 | "name": "Sri Lankan Rupee", 785 | "symbol_native": "SL Re", 786 | "decimal_digits": 2, 787 | "rounding": 0, 788 | "code": "LKR", 789 | "emoji": "🇱🇰", 790 | "name_plural": "Sri Lankan rupees", 791 | "price": true 792 | }, 793 | "LTL": { 794 | "symbol": "Lt", 795 | "name": "Lithuanian Litas", 796 | "symbol_native": "Lt", 797 | "decimal_digits": 2, 798 | "rounding": 0, 799 | "code": "LTL", 800 | "emoji": "", 801 | "name_plural": "Lithuanian litai" 802 | }, 803 | "LVL": { 804 | "symbol": "Ls", 805 | "name": "Latvian Lats", 806 | "symbol_native": "Ls", 807 | "decimal_digits": 2, 808 | "rounding": 0, 809 | "code": "LVL", 810 | "emoji": "", 811 | "name_plural": "Latvian lati" 812 | }, 813 | "LYD": { 814 | "symbol": "LD", 815 | "name": "Libyan Dinar", 816 | "symbol_native": "د.ل.‏", 817 | "decimal_digits": 3, 818 | "rounding": 0, 819 | "code": "LYD", 820 | "emoji": "", 821 | "name_plural": "Libyan dinars" 822 | }, 823 | "MAD": { 824 | "symbol": "MAD", 825 | "name": "Moroccan Dirham", 826 | "symbol_native": "د.م.‏", 827 | "decimal_digits": 2, 828 | "rounding": 0, 829 | "code": "MAD", 830 | "emoji": "🇲🇦", 831 | "name_plural": "Moroccan dirhams", 832 | "price": true 833 | }, 834 | "MDL": { 835 | "symbol": "MDL", 836 | "name": "Moldovan Leu", 837 | "symbol_native": "MDL", 838 | "decimal_digits": 2, 839 | "rounding": 0, 840 | "code": "MDL", 841 | "emoji": "", 842 | "name_plural": "Moldovan lei" 843 | }, 844 | "MGA": { 845 | "symbol": "MGA", 846 | "name": "Malagasy Ariary", 847 | "symbol_native": "MGA", 848 | "decimal_digits": 0, 849 | "rounding": 0, 850 | "code": "MGA", 851 | "emoji": "", 852 | "name_plural": "Malagasy Ariaries" 853 | }, 854 | "MKD": { 855 | "symbol": "MKD", 856 | "name": "Macedonian Denar", 857 | "symbol_native": "MKD", 858 | "decimal_digits": 2, 859 | "rounding": 0, 860 | "code": "MKD", 861 | "emoji": "", 862 | "name_plural": "Macedonian denari" 863 | }, 864 | "MLC": { 865 | "symbol": "MLC", 866 | "name": "Moneda Libremente Convertible", 867 | "symbol_native": "$", 868 | "decimal_digits": 2, 869 | "rounding": 0, 870 | "code": "MLC", 871 | "emoji": "🇨🇺", 872 | "name_plural": "MLC", 873 | "price": true, 874 | "locale": "es-AR" 875 | }, 876 | "MMK": { 877 | "symbol": "MMK", 878 | "name": "Myanma Kyat", 879 | "symbol_native": "K", 880 | "decimal_digits": 0, 881 | "rounding": 0, 882 | "code": "MMK", 883 | "emoji": "", 884 | "name_plural": "Myanma kyats" 885 | }, 886 | "MOP": { 887 | "symbol": "MOP$", 888 | "name": "Macanese Pataca", 889 | "symbol_native": "MOP$", 890 | "decimal_digits": 2, 891 | "rounding": 0, 892 | "code": "MOP", 893 | "emoji": "", 894 | "name_plural": "Macanese patacas" 895 | }, 896 | "MUR": { 897 | "symbol": "MURs", 898 | "name": "Mauritian Rupee", 899 | "symbol_native": "MURs", 900 | "decimal_digits": 0, 901 | "rounding": 0, 902 | "code": "MUR", 903 | "emoji": "", 904 | "name_plural": "Mauritian rupees" 905 | }, 906 | "MXN": { 907 | "symbol": "MX$", 908 | "name": "Peso mexicano", 909 | "symbol_native": "$", 910 | "decimal_digits": 2, 911 | "rounding": 0, 912 | "code": "MXN", 913 | "emoji": "🇲🇽", 914 | "name_plural": "Pesos", 915 | "price": true, 916 | "locale": "es-MX" 917 | }, 918 | "MYR": { 919 | "symbol": "RM", 920 | "name": "Malaysian Ringgit", 921 | "symbol_native": "RM", 922 | "decimal_digits": 2, 923 | "rounding": 0, 924 | "code": "MYR", 925 | "emoji": "🇲🇾", 926 | "name_plural": "Malaysian ringgits", 927 | "price": true 928 | }, 929 | "MZN": { 930 | "symbol": "MTn", 931 | "name": "Mozambican Metical", 932 | "symbol_native": "MTn", 933 | "decimal_digits": 2, 934 | "rounding": 0, 935 | "code": "MZN", 936 | "emoji": "", 937 | "name_plural": "Mozambican meticals" 938 | }, 939 | "NAD": { 940 | "symbol": "N$", 941 | "name": "Namibian Dollar", 942 | "symbol_native": "N$", 943 | "decimal_digits": 2, 944 | "rounding": 0, 945 | "code": "NAD", 946 | "emoji": "🇳🇦", 947 | "name_plural": "Namibian dollars", 948 | "price": true 949 | }, 950 | "NGN": { 951 | "symbol": "₦", 952 | "name": "Nigerian Naira", 953 | "symbol_native": "₦", 954 | "decimal_digits": 2, 955 | "rounding": 0, 956 | "code": "NGN", 957 | "emoji": "🇳🇬", 958 | "name_plural": "Nigerian nairas", 959 | "price": true 960 | }, 961 | "NIO": { 962 | "symbol": "C$", 963 | "name": "Nicaraguan Córdoba", 964 | "symbol_native": "C$", 965 | "decimal_digits": 2, 966 | "rounding": 0, 967 | "code": "NIO", 968 | "emoji": "🇳🇮", 969 | "name_plural": "Nicaraguan córdobas", 970 | "price": true 971 | }, 972 | "NOK": { 973 | "symbol": "Nkr", 974 | "name": "Norwegian Krone", 975 | "symbol_native": "kr", 976 | "decimal_digits": 2, 977 | "rounding": 0, 978 | "code": "NOK", 979 | "emoji": "🇳🇴", 980 | "name_plural": "Norwegian kroner", 981 | "price": true 982 | }, 983 | "NPR": { 984 | "symbol": "NPRs", 985 | "name": "Nepalese Rupee", 986 | "symbol_native": "नेरू", 987 | "decimal_digits": 2, 988 | "rounding": 0, 989 | "code": "NPR", 990 | "emoji": "🇳🇵", 991 | "name_plural": "Nepalese rupees", 992 | "price": true 993 | }, 994 | "NZD": { 995 | "symbol": "NZ$", 996 | "name": "New Zealand Dollar", 997 | "symbol_native": "$", 998 | "decimal_digits": 2, 999 | "rounding": 0, 1000 | "code": "NZD", 1001 | "emoji": "🇳🇿", 1002 | "name_plural": "New Zealand dollars", 1003 | "price": true 1004 | }, 1005 | "OMR": { 1006 | "symbol": "OMR", 1007 | "name": "Omani Rial", 1008 | "symbol_native": "ر.ع.‏", 1009 | "decimal_digits": 3, 1010 | "rounding": 0, 1011 | "code": "OMR", 1012 | "emoji": "", 1013 | "name_plural": "Omani rials" 1014 | }, 1015 | "PAB": { 1016 | "symbol": "B/.", 1017 | "name": "Panamanian Balboa", 1018 | "symbol_native": "B/.", 1019 | "decimal_digits": 2, 1020 | "rounding": 0, 1021 | "code": "PAB", 1022 | "emoji": "🇵🇦", 1023 | "name_plural": "Balboas", 1024 | "price": true 1025 | }, 1026 | "PEN": { 1027 | "symbol": "S/.", 1028 | "name": "Peruvian Nuevo Sol", 1029 | "symbol_native": "S/.", 1030 | "decimal_digits": 2, 1031 | "rounding": 0, 1032 | "code": "PEN", 1033 | "emoji": "🇵🇪", 1034 | "name_plural": "Nuevos soles peruanos", 1035 | "price": true, 1036 | "locale": "es-PE" 1037 | }, 1038 | "PHP": { 1039 | "symbol": "₱", 1040 | "name": "Philippine Peso", 1041 | "symbol_native": "₱", 1042 | "decimal_digits": 2, 1043 | "rounding": 0, 1044 | "code": "PHP", 1045 | "emoji": "🇵🇭", 1046 | "name_plural": "Pesos", 1047 | "price": true 1048 | }, 1049 | "PKR": { 1050 | "symbol": "PKRs", 1051 | "name": "Pakistani Rupee", 1052 | "symbol_native": "₨", 1053 | "decimal_digits": 0, 1054 | "rounding": 0, 1055 | "code": "PKR", 1056 | "emoji": "🇵🇰", 1057 | "name_plural": "Pakistani rupees", 1058 | "price": true 1059 | }, 1060 | "PLN": { 1061 | "symbol": "zł", 1062 | "name": "Polish Zloty", 1063 | "symbol_native": "zł", 1064 | "decimal_digits": 2, 1065 | "rounding": 0, 1066 | "code": "PLN", 1067 | "emoji": "🇵🇱", 1068 | "name_plural": "Polish zlotys", 1069 | "price": true 1070 | }, 1071 | "PYG": { 1072 | "symbol": "₲", 1073 | "name": "Paraguayan Guarani", 1074 | "symbol_native": "₲", 1075 | "decimal_digits": 0, 1076 | "rounding": 0, 1077 | "code": "PYG", 1078 | "emoji": "🇵🇾", 1079 | "name_plural": "Guaranis", 1080 | "price": true 1081 | }, 1082 | "QAR": { 1083 | "symbol": "QR", 1084 | "name": "Qatari Rial", 1085 | "symbol_native": "ر.ق.‏", 1086 | "decimal_digits": 2, 1087 | "rounding": 0, 1088 | "code": "QAR", 1089 | "emoji": "🇶🇦", 1090 | "name_plural": "Qatari rials", 1091 | "price": true 1092 | }, 1093 | "RON": { 1094 | "symbol": "RON", 1095 | "name": "Romanian Leu", 1096 | "symbol_native": "RON", 1097 | "decimal_digits": 2, 1098 | "rounding": 0, 1099 | "code": "RON", 1100 | "emoji": "🇷🇴", 1101 | "name_plural": "Romanian lei", 1102 | "price": true 1103 | }, 1104 | "RSD": { 1105 | "symbol": "din.", 1106 | "name": "Serbian Dinar", 1107 | "symbol_native": "дин.", 1108 | "decimal_digits": 0, 1109 | "rounding": 0, 1110 | "code": "RSD", 1111 | "emoji": "", 1112 | "name_plural": "Serbian dinars", 1113 | "price": true 1114 | }, 1115 | "RUB": { 1116 | "symbol": "RUB", 1117 | "name": "руб", 1118 | "symbol_native": "₽.", 1119 | "decimal_digits": 2, 1120 | "rounding": 0, 1121 | "code": "RUB", 1122 | "emoji": "🇷🇺", 1123 | "name_plural": "руб", 1124 | "price": true 1125 | }, 1126 | "RWF": { 1127 | "symbol": "RWF", 1128 | "name": "Rwandan Franc", 1129 | "symbol_native": "FR", 1130 | "decimal_digits": 0, 1131 | "rounding": 0, 1132 | "code": "RWF", 1133 | "emoji": "", 1134 | "name_plural": "Rwandan francs" 1135 | }, 1136 | "SAR": { 1137 | "symbol": "SR", 1138 | "name": "Saudi Riyal", 1139 | "symbol_native": "ر.س.‏", 1140 | "decimal_digits": 2, 1141 | "rounding": 0, 1142 | "code": "SAR", 1143 | "emoji": "🇸🇦", 1144 | "name_plural": "Saudi riyals", 1145 | "price": true 1146 | }, 1147 | "SDG": { 1148 | "symbol": "SDG", 1149 | "name": "Sudanese Pound", 1150 | "symbol_native": "SDG", 1151 | "decimal_digits": 2, 1152 | "rounding": 0, 1153 | "code": "SDG", 1154 | "emoji": "", 1155 | "name_plural": "Sudanese pounds" 1156 | }, 1157 | "SEK": { 1158 | "symbol": "Skr", 1159 | "name": "Swedish Krona", 1160 | "symbol_native": "kr", 1161 | "decimal_digits": 2, 1162 | "rounding": 0, 1163 | "code": "SEK", 1164 | "emoji": "🇸🇪", 1165 | "name_plural": "Swedish kronor", 1166 | "price": true 1167 | }, 1168 | "SGD": { 1169 | "symbol": "S$", 1170 | "name": "Singapore Dollar", 1171 | "symbol_native": "$", 1172 | "decimal_digits": 2, 1173 | "rounding": 0, 1174 | "code": "SGD", 1175 | "emoji": "🇸🇬", 1176 | "name_plural": "Singapore dollars", 1177 | "price": true 1178 | }, 1179 | "SOS": { 1180 | "symbol": "Ssh", 1181 | "name": "Somali Shilling", 1182 | "symbol_native": "Ssh", 1183 | "decimal_digits": 0, 1184 | "rounding": 0, 1185 | "code": "SOS", 1186 | "emoji": "", 1187 | "name_plural": "Somali shillings" 1188 | }, 1189 | "SYP": { 1190 | "symbol": "SY£", 1191 | "name": "Syrian Pound", 1192 | "symbol_native": "ل.س.‏", 1193 | "decimal_digits": 0, 1194 | "rounding": 0, 1195 | "code": "SYP", 1196 | "emoji": "", 1197 | "name_plural": "Syrian pounds" 1198 | }, 1199 | "THB": { 1200 | "symbol": "฿", 1201 | "name": "Thai Baht", 1202 | "symbol_native": "฿", 1203 | "decimal_digits": 2, 1204 | "rounding": 0, 1205 | "code": "THB", 1206 | "emoji": "🇹🇭", 1207 | "name_plural": "Thai baht", 1208 | "price": true 1209 | }, 1210 | "TND": { 1211 | "symbol": "DT", 1212 | "name": "Tunisian Dinar", 1213 | "symbol_native": "د.ت.‏", 1214 | "decimal_digits": 3, 1215 | "rounding": 0, 1216 | "code": "TND", 1217 | "emoji": "🇹🇳", 1218 | "name_plural": "Tunisian dinars", 1219 | "price": true 1220 | }, 1221 | "TOP": { 1222 | "symbol": "T$", 1223 | "name": "Tongan Paʻanga", 1224 | "symbol_native": "T$", 1225 | "decimal_digits": 2, 1226 | "rounding": 0, 1227 | "code": "TOP", 1228 | "emoji": "", 1229 | "name_plural": "Tongan paʻanga" 1230 | }, 1231 | "TRY": { 1232 | "symbol": "TL", 1233 | "name": "Turkish Lira", 1234 | "symbol_native": "TL", 1235 | "decimal_digits": 2, 1236 | "rounding": 0, 1237 | "code": "TRY", 1238 | "emoji": "🇹🇷", 1239 | "name_plural": "Turkish Lira", 1240 | "price": true 1241 | }, 1242 | "TTD": { 1243 | "symbol": "TT$", 1244 | "name": "Trinidad and Tobago Dollar", 1245 | "symbol_native": "$", 1246 | "decimal_digits": 2, 1247 | "rounding": 0, 1248 | "code": "TTD", 1249 | "emoji": "🇹🇹", 1250 | "name_plural": "Trinidad and Tobago dollars", 1251 | "price": true 1252 | }, 1253 | "TWD": { 1254 | "symbol": "NT$", 1255 | "name": "New Taiwan Dollar", 1256 | "symbol_native": "NT$", 1257 | "decimal_digits": 2, 1258 | "rounding": 0, 1259 | "code": "TWD", 1260 | "emoji": "🇹🇼", 1261 | "name_plural": "New Taiwan dollars", 1262 | "price": true 1263 | }, 1264 | "TZS": { 1265 | "symbol": "TSh", 1266 | "name": "Tanzanian Shilling", 1267 | "symbol_native": "TSh", 1268 | "decimal_digits": 0, 1269 | "rounding": 0, 1270 | "code": "TZS", 1271 | "emoji": "🇹🇿", 1272 | "name_plural": "Tanzanian shillings", 1273 | "price": true 1274 | }, 1275 | "UAH": { 1276 | "symbol": "₴", 1277 | "name": "Ukrainian Hryvnia", 1278 | "symbol_native": "₴", 1279 | "decimal_digits": 2, 1280 | "rounding": 0, 1281 | "code": "UAH", 1282 | "emoji": "🇺🇦", 1283 | "name_plural": "Ukrainian hryvnias", 1284 | "price": true 1285 | }, 1286 | "UGX": { 1287 | "symbol": "USh", 1288 | "name": "Ugandan Shilling", 1289 | "symbol_native": "USh", 1290 | "decimal_digits": 0, 1291 | "rounding": 0, 1292 | "code": "UGX", 1293 | "emoji": "🇺🇬", 1294 | "name_plural": "Ugandan shillings", 1295 | "price": true 1296 | }, 1297 | "USD": { 1298 | "symbol": "$", 1299 | "name": "US Dollar", 1300 | "symbol_native": "$", 1301 | "decimal_digits": 2, 1302 | "rounding": 0, 1303 | "code": "USD", 1304 | "emoji": "🇺🇸", 1305 | "name_plural": "US dollars", 1306 | "price": true, 1307 | "locale": "en-US" 1308 | }, 1309 | "USDSV": { 1310 | "symbol": "$", 1311 | "name": "USD en El Salvador", 1312 | "symbol_native": "$", 1313 | "decimal_digits": 2, 1314 | "rounding": 0, 1315 | "code": "USDSV", 1316 | "emoji": "🇺🇸🇸🇻", 1317 | "name_plural": "USD en El Salvador", 1318 | "price": true, 1319 | "locale": "es-SV" 1320 | }, 1321 | "USDVE": { 1322 | "symbol": "$", 1323 | "name": "USD en Bs", 1324 | "symbol_native": "$", 1325 | "decimal_digits": 2, 1326 | "rounding": 0, 1327 | "code": "USDVE", 1328 | "emoji": "🇺🇸🇻🇪", 1329 | "name_plural": "USD en Bs", 1330 | "price": true, 1331 | "locale": "es-VE" 1332 | }, 1333 | "USDUY": { 1334 | "symbol": "$", 1335 | "name": "USD en Uruguay", 1336 | "symbol_native": "$", 1337 | "decimal_digits": 2, 1338 | "rounding": 0, 1339 | "code": "USDUY", 1340 | "emoji": "🇺🇸🇺🇾", 1341 | "name_plural": "USD en Uruguay", 1342 | "price": true, 1343 | "locale": "es-UY" 1344 | }, 1345 | "UYU": { 1346 | "symbol": "$U", 1347 | "name": "Peso uruguayo", 1348 | "symbol_native": "$", 1349 | "decimal_digits": 2, 1350 | "rounding": 0, 1351 | "code": "UYU", 1352 | "emoji": "🇺🇾", 1353 | "name_plural": "Pesos", 1354 | "price": true, 1355 | "locale": "es-UY" 1356 | }, 1357 | "UZS": { 1358 | "symbol": "UZS", 1359 | "name": "Uzbekistan Som", 1360 | "symbol_native": "UZS", 1361 | "decimal_digits": 0, 1362 | "rounding": 0, 1363 | "code": "UZS", 1364 | "emoji": "🇺🇿", 1365 | "name_plural": "Uzbekistan som", 1366 | "price": true 1367 | }, 1368 | "VES": { 1369 | "symbol": "Bs.", 1370 | "name": "Bolívar", 1371 | "symbol_native": "Bs.", 1372 | "decimal_digits": 2, 1373 | "rounding": 0, 1374 | "code": "VES", 1375 | "emoji": "🇻🇪", 1376 | "name_plural": "Bolívares", 1377 | "price": true, 1378 | "locale": "es-VE" 1379 | }, 1380 | "VND": { 1381 | "symbol": "₫", 1382 | "name": "Vietnamese Dong", 1383 | "symbol_native": "₫", 1384 | "decimal_digits": 0, 1385 | "rounding": 0, 1386 | "code": "VND", 1387 | "emoji": "🇻🇳", 1388 | "name_plural": "Vietnamese dong", 1389 | "price": true 1390 | }, 1391 | "XAF": { 1392 | "symbol": "FCFA", 1393 | "name": "CFA Franc BEAC", 1394 | "symbol_native": "FCFA", 1395 | "decimal_digits": 0, 1396 | "rounding": 0, 1397 | "code": "XAF", 1398 | "emoji": "🏳️", 1399 | "name_plural": "CFA francs BEAC", 1400 | "price": true 1401 | }, 1402 | "XOF": { 1403 | "symbol": "CFA", 1404 | "name": "CFA Franc BCEAO", 1405 | "symbol_native": "CFA", 1406 | "decimal_digits": 0, 1407 | "rounding": 0, 1408 | "code": "XOF", 1409 | "emoji": "🏳️", 1410 | "name_plural": "CFA francs BCEAO", 1411 | "price": true 1412 | }, 1413 | "YER": { 1414 | "symbol": "YR", 1415 | "name": "Yemeni Rial", 1416 | "symbol_native": "ر.ي.‏", 1417 | "decimal_digits": 0, 1418 | "rounding": 0, 1419 | "code": "YER", 1420 | "emoji": "", 1421 | "name_plural": "Yemeni rials" 1422 | }, 1423 | "ZAR": { 1424 | "symbol": "R", 1425 | "name": "South African Rand", 1426 | "symbol_native": "R", 1427 | "decimal_digits": 2, 1428 | "rounding": 0, 1429 | "code": "ZAR", 1430 | "emoji": "🇿🇦", 1431 | "name_plural": "South African rand", 1432 | "price": true 1433 | }, 1434 | "ZMK": { 1435 | "symbol": "ZK", 1436 | "name": "Zambian Kwacha", 1437 | "symbol_native": "ZK", 1438 | "decimal_digits": 0, 1439 | "rounding": 0, 1440 | "code": "ZMK", 1441 | "emoji": "", 1442 | "name_plural": "Zambian kwachas" 1443 | }, 1444 | "ZWL": { 1445 | "symbol": "ZWL$", 1446 | "name": "Zimbabwean Dollar", 1447 | "symbol_native": "ZWL$", 1448 | "decimal_digits": 0, 1449 | "rounding": 0, 1450 | "code": "ZWL", 1451 | "emoji": "🇿🇼", 1452 | "name_plural": "Zimbabwean Dollar" 1453 | } 1454 | }"#; 1455 | 1456 | let fiat_json: FiatNames = serde_json::from_str(fiat_names).unwrap(); 1457 | 1458 | let mut fiatlist = FiatList::new(); 1459 | 1460 | for elem in fiat_json.iter() { 1461 | fiatlist.push((elem.0.to_string(), elem.1.name.clone())); 1462 | } 1463 | 1464 | //Return list 1465 | fiatlist.sort_by(|a, b| a.0.cmp(&b.0)); 1466 | 1467 | fiatlist 1468 | } 1469 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub mod db; 3 | pub mod error; 4 | pub mod lightning; 5 | pub mod nip33; 6 | pub mod pretty_table; 7 | pub mod util; 8 | -------------------------------------------------------------------------------- /src/lightning/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::error::MostroError; 2 | use lightning_invoice::Bolt11Invoice as Invoice; 3 | use std::str::FromStr; 4 | 5 | /// Verify if an invoice is valid 6 | pub fn is_valid_invoice(payment_request: &str) -> Result { 7 | let invoice = Invoice::from_str(payment_request)?; 8 | if invoice.is_expired() { 9 | return Err(MostroError::InvoiceExpiredError); 10 | } 11 | 12 | Ok(invoice) 13 | } 14 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use dotenvy::dotenv; 3 | use mostro_client::cli::run; 4 | use std::process; 5 | 6 | #[tokio::main] 7 | async fn main() -> Result<()> { 8 | dotenv().ok(); 9 | 10 | if let Err(e) = run().await { 11 | eprintln!("{e}"); 12 | process::exit(1); 13 | } 14 | 15 | process::exit(0); 16 | } 17 | -------------------------------------------------------------------------------- /src/nip33.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Ok, Result}; 2 | use mostro_core::prelude::*; 3 | use nostr_sdk::prelude::*; 4 | use std::str::FromStr; 5 | use uuid::Uuid; 6 | 7 | pub fn order_from_tags(tags: Tags) -> Result { 8 | let mut order = SmallOrder::default(); 9 | 10 | for tag in tags { 11 | let t = tag.to_vec(); // Vec 12 | if t.is_empty() { 13 | continue; 14 | } 15 | 16 | let key = t[0].as_str(); 17 | let values = &t[1..]; 18 | 19 | let v = values.first().map(|s| s.as_str()).unwrap_or_default(); 20 | 21 | match key { 22 | "d" => { 23 | order.id = Uuid::parse_str(v).ok(); 24 | } 25 | "k" => { 26 | order.kind = mostro_core::order::Kind::from_str(v).ok(); 27 | } 28 | "f" => { 29 | order.fiat_code = v.to_string(); 30 | } 31 | "s" => { 32 | order.status = Status::from_str(v).ok().or(Some(Status::Pending)); 33 | } 34 | "amt" => { 35 | order.amount = v.parse::().unwrap_or(0); 36 | } 37 | "fa" => { 38 | if v.contains('.') { 39 | continue; 40 | } 41 | if let Some(max_str) = values.get(1) { 42 | order.min_amount = v.parse::().ok(); 43 | order.max_amount = max_str.parse::().ok(); 44 | } else { 45 | order.fiat_amount = v.parse::().unwrap_or(0); 46 | } 47 | } 48 | "pm" => { 49 | order.payment_method = values.join(","); 50 | } 51 | "premium" => { 52 | order.premium = v.parse::().unwrap_or(0); 53 | } 54 | _ => {} 55 | } 56 | } 57 | 58 | Ok(order) 59 | } 60 | 61 | pub fn dispute_from_tags(tags: Tags) -> Result { 62 | let mut dispute = Dispute::default(); 63 | for tag in tags { 64 | let t = tag.to_vec(); 65 | let v = t.get(1).unwrap().as_str(); 66 | match t.first().unwrap().as_str() { 67 | "d" => { 68 | let id = t.get(1).unwrap().as_str().parse::(); 69 | let id = match id { 70 | core::result::Result::Ok(id) => id, 71 | Err(_) => return Err(anyhow::anyhow!("Invalid dispute id")), 72 | }; 73 | dispute.id = id; 74 | } 75 | 76 | "s" => { 77 | let status = match DisputeStatus::from_str(v) { 78 | core::result::Result::Ok(status) => status, 79 | Err(_) => return Err(anyhow::anyhow!("Invalid dispute status")), 80 | }; 81 | 82 | dispute.status = status.to_string(); 83 | } 84 | 85 | _ => {} 86 | } 87 | } 88 | 89 | Ok(dispute) 90 | } 91 | -------------------------------------------------------------------------------- /src/pretty_table.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use chrono::DateTime; 3 | use comfy_table::presets::UTF8_FULL; 4 | use comfy_table::*; 5 | use mostro_core::prelude::*; 6 | 7 | 8 | pub fn print_order_preview(ord: Payload) -> Result { 9 | let single_order = match ord { 10 | Payload::Order(o) => o, 11 | _ => return Err("Error".to_string()), 12 | }; 13 | 14 | let mut table = Table::new(); 15 | 16 | table 17 | .load_preset(UTF8_FULL) 18 | .set_content_arrangement(ContentArrangement::Dynamic) 19 | .set_width(160) 20 | .set_header(vec![ 21 | Cell::new("Buy/Sell") 22 | .add_attribute(Attribute::Bold) 23 | .set_alignment(CellAlignment::Center), 24 | Cell::new("Sats Amount") 25 | .add_attribute(Attribute::Bold) 26 | .set_alignment(CellAlignment::Center), 27 | Cell::new("Fiat Code") 28 | .add_attribute(Attribute::Bold) 29 | .set_alignment(CellAlignment::Center), 30 | Cell::new("Fiat Amount") 31 | .add_attribute(Attribute::Bold) 32 | .set_alignment(CellAlignment::Center), 33 | Cell::new("Payment method") 34 | .add_attribute(Attribute::Bold) 35 | .set_alignment(CellAlignment::Center), 36 | Cell::new("Premium %") 37 | .add_attribute(Attribute::Bold) 38 | .set_alignment(CellAlignment::Center), 39 | ]); 40 | 41 | //Table rows 42 | let r = Row::from(vec![ 43 | if let Some(k) = single_order.kind { 44 | match k { 45 | Kind::Buy => Cell::new(k.to_string()) 46 | .fg(Color::Green) 47 | .set_alignment(CellAlignment::Center), 48 | Kind::Sell => Cell::new(k.to_string()) 49 | .fg(Color::Red) 50 | .set_alignment(CellAlignment::Center), 51 | } 52 | } else { 53 | Cell::new("BUY/SELL").set_alignment(CellAlignment::Center) 54 | }, 55 | if single_order.amount == 0 { 56 | Cell::new("market price").set_alignment(CellAlignment::Center) 57 | } else { 58 | Cell::new(single_order.amount).set_alignment(CellAlignment::Center) 59 | }, 60 | Cell::new(single_order.fiat_code.to_string()).set_alignment(CellAlignment::Center), 61 | // No range order print row 62 | if single_order.min_amount.is_none() && single_order.max_amount.is_none() { 63 | Cell::new(single_order.fiat_amount.to_string()).set_alignment(CellAlignment::Center) 64 | } else { 65 | let range_str = format!( 66 | "{}-{}", 67 | single_order.min_amount.unwrap(), 68 | single_order.max_amount.unwrap() 69 | ); 70 | Cell::new(range_str).set_alignment(CellAlignment::Center) 71 | }, 72 | Cell::new(single_order.payment_method.to_string()).set_alignment(CellAlignment::Center), 73 | Cell::new(single_order.premium.to_string()).set_alignment(CellAlignment::Center), 74 | ]); 75 | 76 | table.add_row(r); 77 | 78 | Ok(table.to_string()) 79 | } 80 | 81 | pub fn print_orders_table(orders_table: Vec) -> Result { 82 | let mut table = Table::new(); 83 | 84 | //Table rows 85 | let mut rows: Vec = Vec::new(); 86 | 87 | if orders_table.is_empty() { 88 | table 89 | .load_preset(UTF8_FULL) 90 | .set_content_arrangement(ContentArrangement::Dynamic) 91 | .set_width(160) 92 | .set_header(vec![Cell::new("Sorry...") 93 | .add_attribute(Attribute::Bold) 94 | .set_alignment(CellAlignment::Center)]); 95 | 96 | // Single row for error 97 | let mut r = Row::new(); 98 | 99 | r.add_cell( 100 | Cell::new("No offers found with requested parameters...") 101 | .fg(Color::Red) 102 | .set_alignment(CellAlignment::Center), 103 | ); 104 | 105 | //Push single error row 106 | rows.push(r); 107 | } else { 108 | table 109 | .load_preset(UTF8_FULL) 110 | .set_content_arrangement(ContentArrangement::Dynamic) 111 | .set_width(160) 112 | .set_header(vec![ 113 | Cell::new("Buy/Sell") 114 | .add_attribute(Attribute::Bold) 115 | .set_alignment(CellAlignment::Center), 116 | Cell::new("Order Id") 117 | .add_attribute(Attribute::Bold) 118 | .set_alignment(CellAlignment::Center), 119 | Cell::new("Status") 120 | .add_attribute(Attribute::Bold) 121 | .set_alignment(CellAlignment::Center), 122 | Cell::new("Amount") 123 | .add_attribute(Attribute::Bold) 124 | .set_alignment(CellAlignment::Center), 125 | Cell::new("Fiat Code") 126 | .add_attribute(Attribute::Bold) 127 | .set_alignment(CellAlignment::Center), 128 | Cell::new("Fiat Amount") 129 | .add_attribute(Attribute::Bold) 130 | .set_alignment(CellAlignment::Center), 131 | Cell::new("Payment method") 132 | .add_attribute(Attribute::Bold) 133 | .set_alignment(CellAlignment::Center), 134 | Cell::new("Created") 135 | .add_attribute(Attribute::Bold) 136 | .set_alignment(CellAlignment::Center), 137 | ]); 138 | 139 | //Iterate to create table of orders 140 | for single_order in orders_table.into_iter() { 141 | let date = DateTime::from_timestamp(single_order.created_at.unwrap_or(0), 0); 142 | 143 | let r = Row::from(vec![ 144 | if let Some(k) = single_order.kind { 145 | match k { 146 | Kind::Buy => Cell::new(k.to_string()) 147 | .fg(Color::Green) 148 | .set_alignment(CellAlignment::Center), 149 | Kind::Sell => Cell::new(k.to_string()) 150 | .fg(Color::Red) 151 | .set_alignment(CellAlignment::Center), 152 | } 153 | } else { 154 | Cell::new("BUY/SELL").set_alignment(CellAlignment::Center) 155 | }, 156 | Cell::new(single_order.id.unwrap()).set_alignment(CellAlignment::Center), 157 | Cell::new(single_order.status.unwrap().to_string()) 158 | .set_alignment(CellAlignment::Center), 159 | if single_order.amount == 0 { 160 | Cell::new("market price").set_alignment(CellAlignment::Center) 161 | } else { 162 | Cell::new(single_order.amount.to_string()).set_alignment(CellAlignment::Center) 163 | }, 164 | Cell::new(single_order.fiat_code.to_string()).set_alignment(CellAlignment::Center), 165 | // No range order print row 166 | if single_order.min_amount.is_none() && single_order.max_amount.is_none() { 167 | Cell::new(single_order.fiat_amount.to_string()) 168 | .set_alignment(CellAlignment::Center) 169 | } else { 170 | let range_str = format!( 171 | "{}-{}", 172 | single_order.min_amount.unwrap(), 173 | single_order.max_amount.unwrap() 174 | ); 175 | Cell::new(range_str).set_alignment(CellAlignment::Center) 176 | }, 177 | Cell::new(single_order.payment_method.to_string()) 178 | .set_alignment(CellAlignment::Center), 179 | Cell::new(date.unwrap()), 180 | ]); 181 | rows.push(r); 182 | } 183 | } 184 | 185 | table.add_rows(rows); 186 | 187 | Ok(table.to_string()) 188 | } 189 | 190 | pub fn print_disputes_table(disputes_table: Vec) -> Result { 191 | let mut table = Table::new(); 192 | 193 | //Table rows 194 | let mut rows: Vec = Vec::new(); 195 | 196 | if disputes_table.is_empty() { 197 | table 198 | .load_preset(UTF8_FULL) 199 | .set_content_arrangement(ContentArrangement::Dynamic) 200 | .set_width(160) 201 | .set_header(vec![Cell::new("Sorry...") 202 | .add_attribute(Attribute::Bold) 203 | .set_alignment(CellAlignment::Center)]); 204 | 205 | // Single row for error 206 | let mut r = Row::new(); 207 | 208 | r.add_cell( 209 | Cell::new("No disputes found with requested parameters...") 210 | .fg(Color::Red) 211 | .set_alignment(CellAlignment::Center), 212 | ); 213 | 214 | //Push single error row 215 | rows.push(r); 216 | } else { 217 | table 218 | .load_preset(UTF8_FULL) 219 | .set_content_arrangement(ContentArrangement::Dynamic) 220 | .set_width(160) 221 | .set_header(vec![ 222 | Cell::new("Dispute Id") 223 | .add_attribute(Attribute::Bold) 224 | .set_alignment(CellAlignment::Center), 225 | Cell::new("Status") 226 | .add_attribute(Attribute::Bold) 227 | .set_alignment(CellAlignment::Center), 228 | Cell::new("Created") 229 | .add_attribute(Attribute::Bold) 230 | .set_alignment(CellAlignment::Center), 231 | ]); 232 | 233 | //Iterate to create table of orders 234 | for single_dispute in disputes_table.into_iter() { 235 | let date = DateTime::from_timestamp(single_dispute.created_at, 0); 236 | 237 | let r = Row::from(vec![ 238 | Cell::new(single_dispute.id).set_alignment(CellAlignment::Center), 239 | Cell::new(single_dispute.status.to_string()).set_alignment(CellAlignment::Center), 240 | Cell::new(date.unwrap()), 241 | ]); 242 | rows.push(r); 243 | } 244 | } 245 | 246 | table.add_rows(rows); 247 | 248 | Ok(table.to_string()) 249 | } 250 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::nip33::{dispute_from_tags, order_from_tags}; 2 | 3 | use anyhow::{Error, Result}; 4 | use base64::engine::general_purpose; 5 | use base64::Engine; 6 | use dotenvy::var; 7 | use log::{error, info}; 8 | use mostro_core::prelude::*; 9 | use nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey}; 10 | use nostr_sdk::prelude::*; 11 | use std::thread::sleep; 12 | use std::time::Duration; 13 | use std::{fs, path::Path}; 14 | 15 | pub async fn send_dm( 16 | client: &Client, 17 | identity_keys: Option<&Keys>, 18 | trade_keys: &Keys, 19 | receiver_pubkey: &PublicKey, 20 | payload: String, 21 | expiration: Option, 22 | to_user: bool, 23 | ) -> Result<()> { 24 | let pow: u8 = var("POW").unwrap_or('0'.to_string()).parse().unwrap(); 25 | let private = var("SECRET") 26 | .unwrap_or("false".to_string()) 27 | .parse::() 28 | .unwrap(); 29 | let event = if to_user { 30 | // Derive conversation key 31 | let ck = ConversationKey::derive(trade_keys.secret_key(), receiver_pubkey)?; 32 | // Encrypt payload 33 | let encrypted_content = encrypt_to_bytes(&ck, payload.as_bytes())?; 34 | // Encode with base64 35 | let b64decoded_content = general_purpose::STANDARD.encode(encrypted_content); 36 | // Compose builder 37 | EventBuilder::new(nostr_sdk::Kind::PrivateDirectMessage, b64decoded_content) 38 | .pow(pow) 39 | .tag(Tag::public_key(*receiver_pubkey)) 40 | .sign_with_keys(trade_keys)? 41 | } else if private { 42 | let message = Message::from_json(&payload).unwrap(); 43 | // We compose the content, when private we don't sign the payload 44 | let content: (Message, Option) = (message, None); 45 | let content = serde_json::to_string(&content).unwrap(); 46 | // We create the rumor 47 | let rumor = EventBuilder::text_note(content) 48 | .pow(pow) 49 | .build(trade_keys.public_key()); 50 | let mut tags: Vec = Vec::with_capacity(1 + usize::from(expiration.is_some())); 51 | 52 | if let Some(timestamp) = expiration { 53 | tags.push(Tag::expiration(timestamp)); 54 | } 55 | let tags = Tags::from_list(tags); 56 | 57 | EventBuilder::gift_wrap(trade_keys, receiver_pubkey, rumor, tags).await? 58 | } else { 59 | let identity_keys = identity_keys 60 | .ok_or_else(|| Error::msg("identity_keys required when to_user is false"))?; 61 | // We sign the message 62 | let message = Message::from_json(&payload).unwrap(); 63 | let sig = Message::sign(payload.clone(), trade_keys); 64 | // We compose the content 65 | let content = serde_json::to_string(&(message, sig)).unwrap(); 66 | // We create the rumor 67 | let rumor = EventBuilder::text_note(content) 68 | .pow(pow) 69 | .build(trade_keys.public_key()); 70 | let mut tags: Vec = Vec::with_capacity(1 + usize::from(expiration.is_some())); 71 | 72 | if let Some(timestamp) = expiration { 73 | tags.push(Tag::expiration(timestamp)); 74 | } 75 | let tags = Tags::from_list(tags); 76 | 77 | EventBuilder::gift_wrap(identity_keys, receiver_pubkey, rumor, tags).await? 78 | }; 79 | 80 | info!("Sending event: {event:#?}"); 81 | client.send_event(&event).await?; 82 | 83 | Ok(()) 84 | } 85 | 86 | pub async fn connect_nostr() -> Result { 87 | let my_keys = Keys::generate(); 88 | 89 | let relays = var("RELAYS").expect("RELAYS is not set"); 90 | let relays = relays.split(',').collect::>(); 91 | // Create new client 92 | let client = Client::new(my_keys); 93 | // Add relays 94 | for r in relays.into_iter() { 95 | client.add_relay(r).await?; 96 | } 97 | // Connect to relays and keep connection alive 98 | client.connect().await; 99 | 100 | Ok(client) 101 | } 102 | 103 | pub async fn send_message_sync( 104 | client: &Client, 105 | identity_keys: Option<&Keys>, 106 | trade_keys: &Keys, 107 | receiver_pubkey: PublicKey, 108 | message: Message, 109 | wait_for_dm: bool, 110 | to_user: bool, 111 | ) -> Result> { 112 | let message_json = message.as_json().map_err(|_| Error::msg("Failed to serialize message"))?; 113 | // Send dm to receiver pubkey 114 | println!( 115 | "SENDING DM with trade keys: {:?}", 116 | trade_keys.public_key().to_hex() 117 | ); 118 | send_dm( 119 | client, 120 | identity_keys, 121 | trade_keys, 122 | &receiver_pubkey, 123 | message_json, 124 | None, 125 | to_user, 126 | ) 127 | .await?; 128 | // FIXME: This is a hack to wait for the DM to be sent 129 | sleep(Duration::from_secs(2)); 130 | 131 | let dm: Vec<(Message, u64)> = if wait_for_dm { 132 | get_direct_messages(client, trade_keys, 15, to_user).await 133 | } else { 134 | Vec::new() 135 | }; 136 | 137 | Ok(dm) 138 | } 139 | 140 | pub async fn get_direct_messages( 141 | client: &Client, 142 | my_key: &Keys, 143 | since: i64, 144 | from_user: bool, 145 | ) -> Vec<(Message, u64)> { 146 | // We use a fake timestamp to thwart time-analysis attacks 147 | let fake_since = 2880; 148 | let fake_since_time = chrono::Utc::now() 149 | .checked_sub_signed(chrono::Duration::minutes(fake_since)) 150 | .unwrap() 151 | .timestamp() as u64; 152 | 153 | let fake_timestamp = Timestamp::from(fake_since_time); 154 | let filters = if from_user { 155 | let since_time = chrono::Utc::now() 156 | .checked_sub_signed(chrono::Duration::minutes(since)) 157 | .unwrap() 158 | .timestamp() as u64; 159 | let timestamp = Timestamp::from(since_time); 160 | Filter::new() 161 | .kind(nostr_sdk::Kind::PrivateDirectMessage) 162 | .pubkey(my_key.public_key()) 163 | .since(timestamp) 164 | } else { 165 | Filter::new() 166 | .kind(nostr_sdk::Kind::GiftWrap) 167 | .pubkey(my_key.public_key()) 168 | .since(fake_timestamp) 169 | }; 170 | 171 | info!("Request events with event kind : {:?} ", filters.kinds); 172 | 173 | let mut direct_messages: Vec<(Message, u64)> = Vec::new(); 174 | 175 | if let Ok(mostro_req) = client.fetch_events(filters, Duration::from_secs(15)).await { 176 | // Buffer vector for direct messages 177 | // Vector for single order id check - maybe multiple relay could send the same order id? Check unique one... 178 | let mut id_list = Vec::::new(); 179 | 180 | for dm in mostro_req.iter() { 181 | if !id_list.contains(&dm.id) { 182 | id_list.push(dm.id); 183 | let (created_at, message) = if from_user { 184 | let ck = 185 | if let Ok(ck) = ConversationKey::derive(my_key.secret_key(), &dm.pubkey) { 186 | ck 187 | } else { 188 | continue; 189 | }; 190 | let b64decoded_content = 191 | match general_purpose::STANDARD.decode(dm.content.as_bytes()) { 192 | Ok(b64decoded_content) => b64decoded_content, 193 | Err(_) => { 194 | continue; 195 | } 196 | }; 197 | 198 | let unencrypted_content = decrypt_to_bytes(&ck, &b64decoded_content) 199 | .expect("Failed to decrypt message"); 200 | 201 | let message = 202 | String::from_utf8(unencrypted_content).expect("Found invalid UTF-8"); 203 | let message = Message::from_json(&message).expect("Failed on deserializing"); 204 | 205 | (dm.created_at, message) 206 | } else { 207 | let unwrapped_gift = match nip59::extract_rumor(my_key, dm).await { 208 | Ok(u) => u, 209 | Err(_) => { 210 | println!("Error unwrapping gift"); 211 | continue; 212 | } 213 | }; 214 | let (message, _): (Message, Option) = 215 | serde_json::from_str(&unwrapped_gift.rumor.content).unwrap(); 216 | 217 | (unwrapped_gift.rumor.created_at, message) 218 | }; 219 | 220 | // Here we discard messages older than the real since parameter 221 | let since_time = chrono::Utc::now() 222 | .checked_sub_signed(chrono::Duration::minutes(30)) 223 | .unwrap() 224 | .timestamp() as u64; 225 | if created_at.as_u64() < since_time { 226 | continue; 227 | } 228 | direct_messages.push((message, created_at.as_u64())); 229 | } 230 | } 231 | // Return element sorted by second tuple element ( Timestamp ) 232 | direct_messages.sort_by(|a, b| a.1.cmp(&b.1)); 233 | } 234 | 235 | direct_messages 236 | } 237 | 238 | pub async fn get_orders_list( 239 | pubkey: PublicKey, 240 | status: Status, 241 | currency: Option, 242 | kind: Option, 243 | client: &Client, 244 | ) -> Result> { 245 | let since_time = chrono::Utc::now() 246 | .checked_sub_signed(chrono::Duration::days(7)) 247 | .unwrap() 248 | .timestamp() as u64; 249 | 250 | let timestamp = Timestamp::from(since_time); 251 | 252 | let filters = Filter::new() 253 | .author(pubkey) 254 | .limit(50) 255 | .since(timestamp) 256 | .custom_tag(SingleLetterTag::lowercase(Alphabet::Z), "order".to_string()) 257 | .kind(nostr_sdk::Kind::Custom(NOSTR_REPLACEABLE_EVENT_KIND)); 258 | 259 | info!( 260 | "Request to mostro id : {:?} with event kind : {:?} ", 261 | filters.authors, filters.kinds 262 | ); 263 | 264 | // Extracted Orders List 265 | let mut complete_events_list = Vec::::new(); 266 | let mut requested_orders_list = Vec::::new(); 267 | 268 | // Send all requests to relays 269 | if let Ok(mostro_req) = client.fetch_events(filters, Duration::from_secs(15)).await { 270 | // Scan events to extract all orders 271 | for el in mostro_req.iter() { 272 | let order = order_from_tags(el.tags.clone()); 273 | 274 | if order.is_err() { 275 | error!("{order:?}"); 276 | continue; 277 | } 278 | let mut order = order?; 279 | 280 | info!("Found Order id : {:?}", order.id.unwrap()); 281 | 282 | if order.id.is_none() { 283 | info!("Order ID is none"); 284 | continue; 285 | } 286 | 287 | if order.kind.is_none() { 288 | info!("Order kind is none"); 289 | continue; 290 | } 291 | 292 | if order.status.is_none() { 293 | info!("Order status is none"); 294 | continue; 295 | } 296 | 297 | // Get created at field from Nostr event 298 | order.created_at = Some(el.created_at.as_u64() as i64); 299 | 300 | complete_events_list.push(order.clone()); 301 | 302 | if order.status.ne(&Some(status)) { 303 | continue; 304 | } 305 | 306 | if currency.is_some() && order.fiat_code.ne(¤cy.clone().unwrap()) { 307 | continue; 308 | } 309 | 310 | if kind.is_some() && order.kind.ne(&kind) { 311 | continue; 312 | } 313 | // Add just requested orders requested by filtering 314 | requested_orders_list.push(order); 315 | } 316 | } 317 | 318 | // Order all element ( orders ) received to filter - discard disaligned messages 319 | // if an order has an older message with the state we received is discarded for the latest one 320 | requested_orders_list.retain(|keep| { 321 | !complete_events_list 322 | .iter() 323 | .any(|x| x.id == keep.id && x.created_at > keep.created_at) 324 | }); 325 | // Sort by id to remove duplicates 326 | requested_orders_list.sort_by(|a, b| b.id.cmp(&a.id)); 327 | requested_orders_list.dedup_by(|a, b| a.id == b.id); 328 | 329 | // Finally sort list by creation time 330 | requested_orders_list.sort_by(|a, b| b.created_at.cmp(&a.created_at)); 331 | 332 | Ok(requested_orders_list) 333 | } 334 | 335 | pub async fn get_disputes_list(pubkey: PublicKey, client: &Client) -> Result> { 336 | let since_time = chrono::Utc::now() 337 | .checked_sub_signed(chrono::Duration::days(7)) 338 | .unwrap() 339 | .timestamp() as u64; 340 | 341 | let timestamp = Timestamp::from(since_time); 342 | 343 | let filter = Filter::new() 344 | .author(pubkey) 345 | .limit(50) 346 | .since(timestamp) 347 | .custom_tag( 348 | SingleLetterTag::lowercase(Alphabet::Z), 349 | "dispute".to_string(), 350 | ) 351 | .kind(nostr_sdk::Kind::Custom(NOSTR_REPLACEABLE_EVENT_KIND)); 352 | 353 | // Extracted Orders List 354 | let mut disputes_list = Vec::::new(); 355 | 356 | // Send all requests to relays 357 | if let Ok(mostro_req) = client.fetch_events(filter, Duration::from_secs(15)).await { 358 | // Scan events to extract all disputes 359 | for d in mostro_req.iter() { 360 | let dispute = dispute_from_tags(d.tags.clone()); 361 | 362 | if dispute.is_err() { 363 | error!("{dispute:?}"); 364 | continue; 365 | } 366 | let mut dispute = dispute?; 367 | 368 | info!("Found Dispute id : {:?}", dispute.id); 369 | 370 | // Get created at field from Nostr event 371 | dispute.created_at = d.created_at.as_u64() as i64; 372 | disputes_list.push(dispute); 373 | } 374 | } 375 | 376 | let buffer_dispute_list = disputes_list.clone(); 377 | // Order all element ( orders ) received to filter - discard disaligned messages 378 | // if an order has an older message with the state we received is discarded for the latest one 379 | disputes_list.retain(|keep| { 380 | !buffer_dispute_list 381 | .iter() 382 | .any(|x| x.id == keep.id && x.created_at > keep.created_at) 383 | }); 384 | 385 | // Sort by id to remove duplicates 386 | disputes_list.sort_by(|a, b| b.id.cmp(&a.id)); 387 | disputes_list.dedup_by(|a, b| a.id == b.id); 388 | 389 | // Finally sort list by creation time 390 | disputes_list.sort_by(|a, b| b.created_at.cmp(&a.created_at)); 391 | 392 | Ok(disputes_list) 393 | } 394 | 395 | /// Uppercase first letter of a string. 396 | pub fn uppercase_first(s: &str) -> String { 397 | let mut c = s.chars(); 398 | match c.next() { 399 | None => String::new(), 400 | Some(f) => f.to_uppercase().collect::() + c.as_str(), 401 | } 402 | } 403 | 404 | pub fn get_mcli_path() -> String { 405 | let home_dir = dirs::home_dir().expect("Couldn't get home directory"); 406 | let mcli_path = format!("{}/.mcli", home_dir.display()); 407 | if !Path::new(&mcli_path).exists() { 408 | fs::create_dir(&mcli_path).expect("Couldn't create mostro-cli directory in HOME"); 409 | println!("Directory {} created.", mcli_path); 410 | } 411 | 412 | mcli_path 413 | } 414 | -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MostroP2P/mostro-cli/970f4eb71e20b102217e242f51d0bb153307551c/static/logo.png --------------------------------------------------------------------------------