diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37149ba2..8ed515c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,6 @@ jobs: -p cdk --no-default-features, -p cdk --no-default-features --features wallet, -p cdk --no-default-features --features mint, - -p cdk --no-default-features --features "mint swagger", -p cdk-axum, -p cdk-strike, -p cdk-lnbits, diff --git a/.helix/languages.toml b/.helix/languages.toml index 22210359..f86f944a 100644 --- a/.helix/languages.toml +++ b/.helix/languages.toml @@ -1,2 +1,2 @@ [language-server.rust-analyzer.config] -cargo = { features = ["wallet", "mint"] } +cargo = { features = ["wallet", "mint", "swagger"] } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4ae781c0..e7bdaccf 120000 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1 +1 @@ -/nix/store/hjc1qcdcir47vpjxds5sdiiw1sw1n36q-pre-commit-config.json \ No newline at end of file +/nix/store/9bf8g8scpkrma0rwv05b4bd1qc81gihg-pre-commit-config.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f3477baf..bb43cef5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,8 @@ - cdk: Move unit conversion util fn to amount module ([davidcaseria]). - cdk: Remove spent proofs from db when check state is called ([mubarak23]). - cdk: Use `MintUrl` directly in wallet client ([ok300]). -- cdk-cli: Change cdk-cli pay command to melt ([mubarak23]). +- cdk-cli: Change cdk-cli pay command to melt ([mubarak23]). +- cdk: Rename `Wallet::get_proofs` to `Wallet::get_unspent_proofs` ([ok300]). ### Added @@ -49,6 +50,7 @@ - cdk: Wallet verifiys keyset id when first fetching keys ([thesimplekid]). - cdk-mind: Add swagger docs ([ok300]). - cdk: NUT18 payment request support ([thesimplekid]). +- cdk: Add `Wallet::get_proofs_with` ([ok300]). ### Removed - cdk: Remove `MintMeltSettings` since it is no longer used ([lollerfirst]). diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..956ba5ed --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,152 @@ +# Development Guide + +This guide will help you set up your development environment for working with the CDK repository. + +## Prerequisites + +Before you begin, ensure you have: +- Git installed on your system +- GitHub account +- Basic familiarity with command line operations + +## Initial Setup + +### 1. Fork and Clone the Repository + +1. Navigate to the CDK repository on GitHub +2. Click the "Fork" button in the top-right corner +3. Clone your forked repository: +```bash +git clone https://github.com/YOUR-USERNAME/cdk.git +cd cdk +``` + +### 2. Install Nix + + + +CDK uses [Nix](https://nixos.org/explore.html) for building, CI, and managing dev environment. +Note: only `Nix` (the language & package manager) and not the NixOS (the Linux distribution) is needed. +Nix can be installed on any Linux distribution and macOS. + +While it is technically possible to not use Nix, it is highly recommended as +it ensures consistent and reproducible environment for all developers. + +### Install Nix + +You have 2 options to install nix: + +* **RECOMMENDED:** The [Determinate Nix Installer](https://github.com/DeterminateSystems/nix-installer) +* [The official installer](https://nixos.org/download.html) + +Example: + +``` +> nix --version +nix (Nix) 2.9.1 +``` + +The exact version might be different. + +### Enable nix flakes + +If you installed Nix using the "determinate installer" you can skip this step. If you used the "official installer", edit either `~/.config/nix/nix.conf` or `/etc/nix/nix.conf` and add: + +``` +experimental-features = nix-command flakes +``` + +If the Nix installation is in multi-user mode, don’t forget to restart the nix-daemon. + +## Use Nix Shell + +```sh + nix develop -c $SHELL +``` + +## Common Development Tasks + +### Building the Project +```sh +just build +``` + +### Running Unit Tests +```bash +just test +``` + +### Running Integration Tests +```bash +just itest REDB/SQLITE/MEMEORY +``` + +### Running Format +```bash +just format +``` + + +### Running Clippy +```bash +just clippy +``` + +### Running final check before commit +```sh +just final-check +``` + + +## Best Practices + +1. **Branch Management** + - Create feature branches from `main` + - Use descriptive branch names: `feature/new-feature` or `fix/bug-description` + +2. **Commit Messages** + - Follow conventional commits format + - Begin with type: `feat:`, `fix:`, `docs:`, `chore:`, etc. + - Provide clear, concise descriptions + +3. **Testing** + - Write tests for new features + - Ensure all tests pass before submitting PR + - Include integration tests where applicable + +## Troubleshooting + +### Common Issues + +1. **Development Shell Issues** + - Clean Nix store: `nix-collect-garbage -d` + - Remove and recreate development shell + +### Getting Help + +- Open an issue on GitHub +- Check existing issues for similar problems +- Include relevant error messages and system information +- Reach out in Discord [Invite link](https://discord.gg/tUxMKd5YjN) + +## Contributing + +1. Create a feature branch +2. Make your changes +3. Run tests and formatting +4. Submit a pull request +5. Wait for review and address feedback + +## Additional Resources + +- [Nix Documentation](https://nixos.org/manual/nix/stable/) +- [Contributing Guidelines](CONTRIBUTING.md) + +## License + +Refer to the LICENSE file in the repository for terms of use and distribution. diff --git a/README.md b/README.md index 174184b2..47eb1b77 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,6 @@ The project is split up into several crates in the `crates/` directory: | [16][16] | Animated QR codes | :x: | | [17][17] | WebSocket subscriptions | :construction: | -MSRV ## Bindings @@ -75,6 +74,9 @@ All contributions welcome. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, shall be licensed as above, without any additional terms or conditions. +Please see the [development guide](DEVELOPMENT.md). + + [00]: https://github.com/cashubtc/nuts/blob/main/00.md [01]: https://github.com/cashubtc/nuts/blob/main/01.md [02]: https://github.com/cashubtc/nuts/blob/main/02.md diff --git a/bindings/cdk-js/src/nuts/nut06.rs b/bindings/cdk-js/src/nuts/nut06.rs index dbbb1912..0f5c8888 100644 --- a/bindings/cdk-js/src/nuts/nut06.rs +++ b/bindings/cdk-js/src/nuts/nut06.rs @@ -78,6 +78,7 @@ impl JsMintInfo { contact: Option>, nuts: JsValue, icon_url: Option, + urls: Option>, motd: Option, time: Option, ) -> Result { @@ -92,6 +93,7 @@ impl JsMintInfo { .map(|contacts| contacts.iter().map(|c| c.deref().clone()).collect()), nuts: serde_wasm_bindgen::from_value(nuts).map_err(into_err)?, icon_url, + urls, motd, time, }, diff --git a/bindings/cdk-js/src/types/melt_quote.rs b/bindings/cdk-js/src/types/melt_quote.rs index d6e6f325..c00ac8b7 100644 --- a/bindings/cdk-js/src/types/melt_quote.rs +++ b/bindings/cdk-js/src/types/melt_quote.rs @@ -33,7 +33,7 @@ impl JsMeltQuote { #[wasm_bindgen(getter)] pub fn unit(&self) -> JsCurrencyUnit { - self.inner.unit.into() + self.inner.unit.clone().into() } #[wasm_bindgen(getter)] diff --git a/bindings/cdk-js/src/types/mint_quote.rs b/bindings/cdk-js/src/types/mint_quote.rs index fed36a92..ebc7f0d7 100644 --- a/bindings/cdk-js/src/types/mint_quote.rs +++ b/bindings/cdk-js/src/types/mint_quote.rs @@ -33,7 +33,7 @@ impl JsMintQuote { #[wasm_bindgen(getter)] pub fn unit(&self) -> JsCurrencyUnit { - self.inner.unit.into() + self.inner.unit.clone().into() } #[wasm_bindgen(getter)] diff --git a/crates/cdk-axum/Cargo.toml b/crates/cdk-axum/Cargo.toml index 241da962..ae458dd4 100644 --- a/crates/cdk-axum/Cargo.toml +++ b/crates/cdk-axum/Cargo.toml @@ -5,21 +5,30 @@ edition = "2021" license = "MIT" homepage = "https://github.com/cashubtc/cdk" repository = "https://github.com/cashubtc/cdk.git" -rust-version = "1.63.0" # MSRV +rust-version = "1.63.0" # MSRV description = "Cashu CDK axum webserver" [dependencies] anyhow = "1" -async-trait = "0.1" -axum = "0.6.20" -cdk = { path = "../cdk", version = "0.4.0", default-features = false, features = ["mint"] } -tokio = { version = "1", default-features = false } -tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } -utoipa = { version = "4", features = ["preserve_order", "preserve_path_order"], optional = true } +async-trait = "0.1.83" +axum = { version = "0.6.20", features = ["ws"] } +cdk = { path = "../cdk", version = "0.4.0", default-features = false, features = [ + "mint", +] } +tokio = { version = "1", default-features = false, features = ["io-util"] } +tracing = { version = "0.1", default-features = false, features = [ + "attributes", + "log", +] } +utoipa = { version = "4", features = [ + "preserve_order", + "preserve_path_order", +], optional = true } futures = { version = "0.3.28", default-features = false } moka = { version = "0.11.1", features = ["future"] } serde_json = "1" paste = "1.0.15" +serde = { version = "1.0.210", features = ["derive"] } [features] -swagger = ["cdk/swagger", "dep:utoipa"] \ No newline at end of file +swagger = ["cdk/swagger", "dep:utoipa"] diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 9083163b..a431f0d6 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -14,6 +14,7 @@ use moka::future::Cache; use router_handlers::*; mod router_handlers; +mod ws; #[cfg(feature = "swagger")] mod swagger_imports { @@ -117,10 +118,10 @@ pub struct MintState { get_keyset_pubkeys, get_keysets, get_mint_info, - get_mint_bolt11_quote, + post_mint_bolt11_quote, get_check_mint_bolt11_quote, post_mint_bolt11, - get_melt_bolt11_quote, + post_melt_bolt11_quote, get_check_melt_bolt11_quote, post_melt_bolt11, post_swap, @@ -147,13 +148,14 @@ pub async fn create_mint_router(mint: Arc, cache_ttl: u64, cache_tti: u64) .route("/keysets", get(get_keysets)) .route("/keys/:keyset_id", get(get_keyset_pubkeys)) .route("/swap", post(cache_post_swap)) - .route("/mint/quote/bolt11", post(get_mint_bolt11_quote)) + .route("/mint/quote/bolt11", post(post_mint_bolt11_quote)) .route( "/mint/quote/bolt11/:quote_id", get(get_check_mint_bolt11_quote), ) .route("/mint/bolt11", post(cache_post_mint_bolt11)) - .route("/melt/quote/bolt11", post(get_melt_bolt11_quote)) + .route("/melt/quote/bolt11", post(post_melt_bolt11_quote)) + .route("/ws", get(ws_handler)) .route( "/melt/quote/bolt11/:quote_id", get(get_check_melt_bolt11_quote), diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index c4ba781a..d3947d40 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use axum::extract::{Json, Path, State}; +use axum::extract::{ws::WebSocketUpgrade, Json, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cdk::error::ErrorResponse; @@ -13,7 +13,7 @@ use cdk::util::unix_time; use cdk::Error; use paste::paste; -use crate::MintState; +use crate::{ws::main_websocket, MintState}; macro_rules! post_cache_wrapper { ($handler:ident, $request_type:ty, $response_type:ty) => { @@ -130,7 +130,7 @@ pub async fn get_keysets(State(state): State) -> Result, Json(payload): Json, ) -> Result, Response> { @@ -174,6 +174,15 @@ pub async fn get_check_mint_bolt11_quote( Ok(Json(quote)) } +pub async fn ws_handler(State(state): State, ws: WebSocketUpgrade) -> impl IntoResponse { + ws.on_upgrade(|ws| main_websocket(ws, state)) +} + +/// Mint tokens by paying a BOLT11 Lightning invoice. +/// +/// Requests the minting of tokens belonging to a paid payment request. +/// +/// Call this endpoint after `POST /v1/mint/quote`. #[cfg_attr(feature = "swagger", utoipa::path( post, context_path = "/v1", @@ -184,11 +193,6 @@ pub async fn get_check_mint_bolt11_quote( (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") ) ))] -/// Mint tokens by paying a BOLT11 Lightning invoice. -/// -/// Requests the minting of tokens belonging to a paid payment request. -/// -/// Call this endpoint after `POST /v1/mint/quote`. pub async fn post_mint_bolt11( State(state): State, Json(payload): Json, @@ -216,7 +220,7 @@ pub async fn post_mint_bolt11( ) ))] /// Request a quote for melting tokens -pub async fn get_melt_bolt11_quote( +pub async fn post_melt_bolt11_quote( State(state): State, Json(payload): Json, ) -> Result, Response> { diff --git a/crates/cdk-axum/src/ws/error.rs b/crates/cdk-axum/src/ws/error.rs new file mode 100644 index 00000000..24fa4c8c --- /dev/null +++ b/crates/cdk-axum/src/ws/error.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +/// Source: https://www.jsonrpc.org/specification#error_object +pub enum WsError { + /// Invalid JSON was received by the server. + /// An error occurred on the server while parsing the JSON text. + ParseError, + /// The JSON sent is not a valid Request object. + InvalidRequest, + /// The method does not exist / is not available. + MethodNotFound, + /// Invalid method parameter(s). + InvalidParams, + /// Internal JSON-RPC error. + InternalError, + /// Custom error + ServerError(i32, String), +} diff --git a/crates/cdk-axum/src/ws/handler.rs b/crates/cdk-axum/src/ws/handler.rs new file mode 100644 index 00000000..ea1ba3ae --- /dev/null +++ b/crates/cdk-axum/src/ws/handler.rs @@ -0,0 +1,70 @@ +use super::{WsContext, WsError, JSON_RPC_VERSION}; +use serde::Serialize; + +impl From for WsErrorResponse { + fn from(val: WsError) -> Self { + let (id, message) = match val { + WsError::ParseError => (-32700, "Parse error".to_string()), + WsError::InvalidRequest => (-32600, "Invalid Request".to_string()), + WsError::MethodNotFound => (-32601, "Method not found".to_string()), + WsError::InvalidParams => (-32602, "Invalid params".to_string()), + WsError::InternalError => (-32603, "Internal error".to_string()), + WsError::ServerError(code, message) => (code, message), + }; + WsErrorResponse { code: id, message } + } +} + +#[derive(Debug, Clone, Serialize)] +struct WsErrorResponse { + code: i32, + message: String, +} + +#[derive(Debug, Clone, Serialize)] +struct WsResponse { + jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + id: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WsNotification { + pub jsonrpc: String, + pub method: String, + pub params: T, +} + +#[async_trait::async_trait] +pub trait WsHandle { + type Response: Serialize + Sized; + + async fn process( + self, + req_id: usize, + context: &mut WsContext, + ) -> Result + where + Self: Sized, + { + serde_json::to_value(&match self.handle(context).await { + Ok(response) => WsResponse { + jsonrpc: JSON_RPC_VERSION.to_owned(), + result: Some(response), + error: None, + id: req_id, + }, + Err(error) => WsResponse { + jsonrpc: JSON_RPC_VERSION.to_owned(), + result: None, + error: Some(error.into()), + id: req_id, + }, + }) + } + + async fn handle(self, context: &mut WsContext) -> Result; +} diff --git a/crates/cdk-axum/src/ws/mod.rs b/crates/cdk-axum/src/ws/mod.rs new file mode 100644 index 00000000..2af2156e --- /dev/null +++ b/crates/cdk-axum/src/ws/mod.rs @@ -0,0 +1,123 @@ +use crate::MintState; +use axum::extract::ws::{Message, WebSocket}; +use cdk::nuts::nut17::{NotificationPayload, SubId}; +use futures::StreamExt; +use handler::{WsHandle, WsNotification}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use subscribe::Notification; +use tokio::sync::mpsc; + +mod error; +mod handler; +mod subscribe; +mod unsubscribe; + +/// JSON RPC version +pub const JSON_RPC_VERSION: &str = "2.0"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WsRequest { + jsonrpc: String, + #[serde(flatten)] + method: WsMethod, + id: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "method", content = "params")] +pub enum WsMethod { + Subscribe(subscribe::Method), + Unsubscribe(unsubscribe::Method), +} + +impl WsMethod { + pub async fn process( + self, + req_id: usize, + context: &mut WsContext, + ) -> Result { + match self { + WsMethod::Subscribe(sub) => sub.process(req_id, context), + WsMethod::Unsubscribe(unsub) => unsub.process(req_id, context), + } + .await + } +} + +pub use error::WsError; + +pub struct WsContext { + state: MintState, + subscriptions: HashMap>, + publisher: mpsc::Sender<(SubId, NotificationPayload)>, +} + +/// Main function for websocket connections +/// +/// This function will handle all incoming websocket connections and keep them in their own loop. +/// +/// For simplicity sake this function will spawn tasks for each subscription and +/// keep them in a hashmap, and will have a single subscriber for all of them. +#[allow(clippy::incompatible_msrv)] +pub async fn main_websocket(mut socket: WebSocket, state: MintState) { + let (publisher, mut subscriber) = mpsc::channel(100); + let mut context = WsContext { + state, + subscriptions: HashMap::new(), + publisher, + }; + + loop { + tokio::select! { + Some((sub_id, payload)) = subscriber.recv() => { + if !context.subscriptions.contains_key(&sub_id) { + // It may be possible an incoming message has come from a dropped Subscriptions that has not yet been + // unsubscribed from the subscription manager, just ignore it. + continue; + } + let notification: WsNotification = (sub_id, payload).into(); + let message = match serde_json::to_string(¬ification) { + Ok(message) => message, + Err(err) => { + tracing::error!("Could not serialize notification: {}", err); + continue; + } + }; + + if let Err(err)= socket.send(Message::Text(message)).await { + tracing::error!("Could not send websocket message: {}", err); + break; + } + } + Some(Ok(Message::Text(text))) = socket.next() => { + let request = match serde_json::from_str::(&text) { + Ok(request) => request, + Err(err) => { + tracing::error!("Could not parse request: {}", err); + continue; + } + }; + + match request.method.process(request.id, &mut context).await { + Ok(result) => { + if let Err(err) = socket + .send(Message::Text(result.to_string())) + .await + { + tracing::error!("Could not send request: {}", err); + break; + } + } + Err(err) => { + tracing::error!("Error serializing response: {}", err); + break; + } + } + } + else => { + + } + } + } +} diff --git a/crates/cdk-axum/src/ws/subscribe.rs b/crates/cdk-axum/src/ws/subscribe.rs new file mode 100644 index 00000000..0a7de158 --- /dev/null +++ b/crates/cdk-axum/src/ws/subscribe.rs @@ -0,0 +1,80 @@ +use super::{ + handler::{WsHandle, WsNotification}, + WsContext, WsError, JSON_RPC_VERSION, +}; +use cdk::{ + nuts::nut17::{NotificationPayload, Params}, + pub_sub::SubId, +}; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Method(Params); + +#[derive(Debug, Clone, serde::Serialize)] +/// The response to a subscription request +pub struct Response { + /// Status + status: String, + /// Subscription ID + #[serde(rename = "subId")] + sub_id: SubId, +} + +#[derive(Debug, Clone, serde::Serialize)] +/// The notification +/// +/// This is the notification that is sent to the client when an event matches a +/// subscription +pub struct Notification { + /// The subscription ID + #[serde(rename = "subId")] + pub sub_id: SubId, + + /// The notification payload + pub payload: NotificationPayload, +} + +impl From<(SubId, NotificationPayload)> for WsNotification { + fn from((sub_id, payload): (SubId, NotificationPayload)) -> Self { + WsNotification { + jsonrpc: JSON_RPC_VERSION.to_owned(), + method: "subscribe".to_string(), + params: Notification { sub_id, payload }, + } + } +} + +#[async_trait::async_trait] +impl WsHandle for Method { + type Response = Response; + + /// The `handle` method is called when a client sends a subscription request + async fn handle(self, context: &mut WsContext) -> Result { + let sub_id = self.0.id.clone(); + if context.subscriptions.contains_key(&sub_id) { + // Subscription ID already exits. Returns an error instead of + // replacing the other subscription or avoiding it. + return Err(WsError::InvalidParams); + } + + let mut subscription = context + .state + .mint + .pubsub_manager + .subscribe(self.0.clone()) + .await; + let publisher = context.publisher.clone(); + context.subscriptions.insert( + sub_id.clone(), + tokio::spawn(async move { + while let Some(response) = subscription.recv().await { + let _ = publisher.send(response).await; + } + }), + ); + Ok(Response { + status: "OK".to_string(), + sub_id, + }) + } +} diff --git a/crates/cdk-axum/src/ws/unsubscribe.rs b/crates/cdk-axum/src/ws/unsubscribe.rs new file mode 100644 index 00000000..421abbae --- /dev/null +++ b/crates/cdk-axum/src/ws/unsubscribe.rs @@ -0,0 +1,30 @@ +use super::{handler::WsHandle, WsContext, WsError}; +use cdk::pub_sub::SubId; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct Method { + #[serde(rename = "subId")] + pub sub_id: SubId, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct Response { + status: String, + sub_id: SubId, +} + +#[async_trait::async_trait] +impl WsHandle for Method { + type Response = Response; + + async fn handle(self, context: &mut WsContext) -> Result { + if context.subscriptions.remove(&self.sub_id).is_some() { + Ok(Response { + status: "OK".to_string(), + sub_id: self.sub_id, + }) + } else { + Err(WsError::InvalidParams) + } + } +} diff --git a/crates/cdk-cli/src/sub_commands/mint_info.rs b/crates/cdk-cli/src/sub_commands/mint_info.rs index bcf9f5ec..739d9934 100644 --- a/crates/cdk-cli/src/sub_commands/mint_info.rs +++ b/crates/cdk-cli/src/sub_commands/mint_info.rs @@ -1,5 +1,6 @@ use anyhow::Result; use cdk::mint_url::MintUrl; +use cdk::wallet::client::HttpClientMethods; use cdk::HttpClient; use clap::Args; use url::Url; diff --git a/crates/cdk-cli/src/sub_commands/pay_request.rs b/crates/cdk-cli/src/sub_commands/pay_request.rs index 1498f980..308df1d0 100644 --- a/crates/cdk-cli/src/sub_commands/pay_request.rs +++ b/crates/cdk-cli/src/sub_commands/pay_request.rs @@ -21,7 +21,7 @@ pub async fn pay_request( ) -> Result<()> { let payment_request = &sub_command_args.payment_request; - let unit = payment_request.unit; + let unit = &payment_request.unit; let amount = match payment_request.amount { Some(amount) => amount, @@ -56,7 +56,7 @@ pub async fn pay_request( } if let Some(unit) = unit { - if wallet.unit != unit { + if &wallet.unit != unit { continue; } } @@ -97,7 +97,7 @@ pub async fn pay_request( id: payment_request.payment_id.clone(), memo: None, mint: matching_wallet.mint_url.clone(), - unit: matching_wallet.unit, + unit: matching_wallet.unit.clone(), proofs, }; diff --git a/crates/cdk-cln/src/lib.rs b/crates/cdk-cln/src/lib.rs index 1e8f609c..b9ed45ee 100644 --- a/crates/cdk-cln/src/lib.rs +++ b/crates/cdk-cln/src/lib.rs @@ -16,10 +16,7 @@ use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, }; use cdk::mint::FeeReserve; -use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, -}; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::{hex, unix_time}; use cdk::{mint, Bolt11Invoice}; use cln_rpc::model::requests::{ @@ -45,28 +42,19 @@ pub struct Cln { rpc_socket: PathBuf, cln_client: Arc>, fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, } impl Cln { /// Create new [`Cln`] - pub async fn new( - rpc_socket: PathBuf, - fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, - ) -> Result { + pub async fn new(rpc_socket: PathBuf, fee_reserve: FeeReserve) -> Result { let cln_client = cln_rpc::ClnRpc::new(&rpc_socket).await?; Ok(Self { rpc_socket, cln_client: Arc::new(Mutex::new(cln_client)), fee_reserve, - mint_settings, - melt_settings, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), }) @@ -81,8 +69,6 @@ impl MintLightning for Cln { Settings { mpp: true, unit: CurrencyUnit::Msat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, invoice_description: true, } } diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index e0fba072..9e287837 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -20,10 +20,7 @@ use cdk::cdk_lightning::{ }; use cdk::mint; use cdk::mint::FeeReserve; -use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, -}; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::unix_time; use error::Error; use futures::stream::StreamExt; @@ -44,8 +41,6 @@ pub struct FakeWallet { fee_reserve: FeeReserve, sender: tokio::sync::mpsc::Sender, receiver: Arc>>>, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, payment_states: Arc>>, failed_payment_check: Arc>>, payment_delay: u64, @@ -57,8 +52,6 @@ impl FakeWallet { /// Creat new [`FakeWallet`] pub fn new( fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, payment_states: HashMap, fail_payment_check: HashSet, payment_delay: u64, @@ -69,8 +62,6 @@ impl FakeWallet { fee_reserve, sender, receiver: Arc::new(Mutex::new(Some(receiver))), - mint_settings, - melt_settings, payment_states: Arc::new(Mutex::new(payment_states)), failed_payment_check: Arc::new(Mutex::new(fail_payment_check)), payment_delay, @@ -112,8 +103,6 @@ impl MintLightning for FakeWallet { Settings { mpp: true, unit: CurrencyUnit::Msat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, invoice_description: true, } } diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index 7291eba9..8753408c 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -7,7 +7,7 @@ description = "Core Cashu Development Kit library implementing the Cashu protoco license = "MIT" homepage = "https://github.com/cashubtc/cdk" repository = "https://github.com/cashubtc/cdk.git" -rust-version = "1.63.0" # MSRV +rust-version = "1.63.0" # MSRV [features] @@ -20,12 +20,14 @@ bip39 = { version = "2.0", features = ["rand"] } anyhow = "1" cdk = { path = "../cdk", version = "0.4.0", features = ["mint", "wallet"] } cdk-cln = { path = "../cdk-cln", version = "0.4.0" } -cdk-axum = { path = "../cdk-axum"} -cdk-sqlite = { path = "../cdk-sqlite"} -cdk-redb = { path = "../cdk-redb"} +cdk-axum = { path = "../cdk-axum" } +cdk-sqlite = { path = "../cdk-sqlite" } +cdk-redb = { path = "../cdk-redb" } cdk-fake-wallet = { path = "../cdk-fake-wallet" } tower-http = { version = "0.4.4", features = ["cors"] } -futures = { version = "0.3.28", default-features = false, features = ["executor"] } +futures = { version = "0.3.28", default-features = false, features = [ + "executor", +] } once_cell = "1.19.0" uuid = { version = "1", features = ["v4"] } serde = "1" @@ -33,9 +35,13 @@ serde_json = "1" # ln-regtest-rs = { path = "../../../../ln-regtest-rs" } ln-regtest-rs = { git = "https://github.com/thesimplekid/ln-regtest-rs", rev = "1d88d3d0b" } lightning-invoice = { version = "0.32.0", features = ["serde", "std"] } -tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } +tracing = { version = "0.1", default-features = false, features = [ + "attributes", + "log", +] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } tower-service = "0.3.3" +tokio-tungstenite = "0.24.0" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1", features = [ @@ -52,7 +58,7 @@ instant = { version = "0.1", features = ["wasm-bindgen", "inaccurate"] } [dev-dependencies] rand = "0.8.5" -bip39 = { version= "2.0", features = ["rand"] } +bip39 = { version = "2.0", features = ["rand"] } anyhow = "1" cdk = { path = "../cdk", features = ["mint", "wallet"] } cdk-axum = { path = "../cdk-axum" } diff --git a/crates/cdk-integration-tests/src/init_fake_wallet.rs b/crates/cdk-integration-tests/src/init_fake_wallet.rs index 3eeaa37f..8fbe7784 100644 --- a/crates/cdk-integration-tests/src/init_fake_wallet.rs +++ b/crates/cdk-integration-tests/src/init_fake_wallet.rs @@ -9,7 +9,7 @@ use cdk::{ cdk_database::{self, MintDatabase}, cdk_lightning::MintLightning, mint::FeeReserve, - nuts::{CurrencyUnit, MeltMethodSettings, MintMethodSettings}, + nuts::CurrencyUnit, types::LnKey, }; use cdk_fake_wallet::FakeWallet; @@ -46,14 +46,7 @@ where percent_fee_reserve: 1.0, }; - let fake_wallet = FakeWallet::new( - fee_reserve, - MintMethodSettings::default(), - MeltMethodSettings::default(), - HashMap::default(), - HashSet::default(), - 0, - ); + let fake_wallet = FakeWallet::new(fee_reserve, HashMap::default(), HashSet::default(), 0); ln_backends.insert( LnKey::new(CurrencyUnit::Sat, cdk::nuts::PaymentMethod::Bolt11), diff --git a/crates/cdk-integration-tests/src/init_regtest.rs b/crates/cdk-integration-tests/src/init_regtest.rs index 769a3350..92d147b5 100644 --- a/crates/cdk-integration-tests/src/init_regtest.rs +++ b/crates/cdk-integration-tests/src/init_regtest.rs @@ -7,7 +7,7 @@ use cdk::{ cdk_database::{self, MintDatabase}, cdk_lightning::MintLightning, mint::{FeeReserve, Mint}, - nuts::{CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings}, + nuts::{CurrencyUnit, MintInfo}, types::{LnKey, QuoteTTL}, }; use cdk_cln::Cln as CdkCln; @@ -45,6 +45,10 @@ pub fn get_mint_url() -> String { format!("http://{}:{}", get_mint_addr(), get_mint_port()) } +pub fn get_mint_ws_url() -> String { + format!("ws://{}:{}/v1/ws", get_mint_addr(), get_mint_port()) +} + pub fn get_temp_dir() -> PathBuf { let dir = env::var("cdk_itests").expect("Temp dir set"); std::fs::create_dir_all(&dir).unwrap(); @@ -131,13 +135,7 @@ pub async fn create_cln_backend(cln_client: &ClnClient) -> Result { percent_fee_reserve: 1.0, }; - Ok(CdkCln::new( - rpc_path, - fee_reserve, - MintMethodSettings::default(), - MeltMethodSettings::default(), - ) - .await?) + Ok(CdkCln::new(rpc_path, fee_reserve).await?) } pub async fn create_mint( @@ -176,6 +174,7 @@ where Arc::new(database), ln_backends, supported_units, + HashMap::new(), ) .await?; diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index eacc3b3a..2e52b034 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -11,11 +11,11 @@ use cdk::cdk_lightning::MintLightning; use cdk::dhke::construct_proofs; use cdk::mint::FeeReserve; use cdk::nuts::{ - CurrencyUnit, Id, KeySet, MeltMethodSettings, MintInfo, MintMethodSettings, MintQuoteState, + CurrencyUnit, Id, KeySet, MintBolt11Request, MintInfo, MintQuoteBolt11Request, MintQuoteState, Nuts, PaymentMethod, PreMintSecrets, Proofs, State, }; use cdk::types::{LnKey, QuoteTTL}; -use cdk::wallet::client::HttpClient; +use cdk::wallet::client::{HttpClient, HttpClientMethods}; use cdk::{Mint, Wallet}; use cdk_fake_wallet::FakeWallet; use init_regtest::{get_mint_addr, get_mint_port, get_mint_url}; @@ -40,8 +40,6 @@ pub fn create_backends_fake_wallet( let wallet = Arc::new(FakeWallet::new( fee_reserve.clone(), - MintMethodSettings::default(), - MeltMethodSettings::default(), HashMap::default(), HashSet::default(), 0, @@ -82,6 +80,7 @@ pub async fn start_mint( Arc::new(MintMemoryDatabase::default()), ln_backends.clone(), supported_units, + HashMap::new(), ) .await?; let cache_time_to_live = 3600; @@ -158,8 +157,14 @@ pub async fn mint_proofs( let wallet_client = HttpClient::new(); + let request = MintQuoteBolt11Request { + amount, + unit: CurrencyUnit::Sat, + description, + }; + let mint_quote = wallet_client - .post_mint_quote(mint_url.parse()?, 1.into(), CurrencyUnit::Sat, description) + .post_mint_quote(mint_url.parse()?, request) .await?; println!("Please pay: {}", mint_quote.request); @@ -179,13 +184,12 @@ pub async fn mint_proofs( let premint_secrets = PreMintSecrets::random(keyset_id, amount, &SplitTarget::default())?; - let mint_response = wallet_client - .post_mint( - mint_url.parse()?, - &mint_quote.quote, - premint_secrets.clone(), - ) - .await?; + let request = MintBolt11Request { + quote: mint_quote.quote, + outputs: premint_secrets.blinded_messages(), + }; + + let mint_response = wallet_client.post_mint(mint_url.parse()?, request).await?; let pre_swap_proofs = construct_proofs( mint_response.signatures, diff --git a/crates/cdk-integration-tests/tests/fake_wallet.rs b/crates/cdk-integration-tests/tests/fake_wallet.rs index e120a4a2..884e63d1 100644 --- a/crates/cdk-integration-tests/tests/fake_wallet.rs +++ b/crates/cdk-integration-tests/tests/fake_wallet.rs @@ -5,8 +5,13 @@ use bip39::Mnemonic; use cdk::{ amount::SplitTarget, cdk_database::WalletMemoryDatabase, - nuts::{CurrencyUnit, MeltQuoteState, PreMintSecrets, State}, - wallet::{client::HttpClient, Wallet}, + nuts::{ + CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintQuoteState, PreMintSecrets, State, + }, + wallet::{ + client::{HttpClient, HttpClientMethods}, + Wallet, + }, }; use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; use cdk_integration_tests::attempt_to_swap_pending; @@ -27,7 +32,7 @@ async fn test_fake_tokens_pending() -> Result<()> { let mint_quote = wallet.mint_quote(100.into(), None).await?; - sleep(Duration::from_millis(5)).await; + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet .mint(&mint_quote.id, SplitTarget::default(), None) @@ -67,7 +72,7 @@ async fn test_fake_melt_payment_fail() -> Result<()> { let mint_quote = wallet.mint_quote(100.into(), None).await?; - sleep(Duration::from_millis(5)).await; + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet .mint(&mint_quote.id, SplitTarget::default(), None) @@ -104,7 +109,7 @@ async fn test_fake_melt_payment_fail() -> Result<()> { assert!(melt.is_err()); // The mint should have unset proofs from pending since payment failed - let all_proof = wallet.get_proofs().await?; + let all_proof = wallet.get_unspent_proofs().await?; let states = wallet.check_proofs_spent(all_proof).await?; for state in states { assert!(state.state == State::Unspent); @@ -130,7 +135,7 @@ async fn test_fake_melt_payment_fail_and_check() -> Result<()> { let mint_quote = wallet.mint_quote(100.into(), None).await?; - sleep(Duration::from_millis(5)).await; + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet .mint(&mint_quote.id, SplitTarget::default(), None) @@ -175,7 +180,7 @@ async fn test_fake_melt_payment_return_fail_status() -> Result<()> { let mint_quote = wallet.mint_quote(100.into(), None).await?; - sleep(Duration::from_millis(5)).await; + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet .mint(&mint_quote.id, SplitTarget::default(), None) @@ -235,7 +240,7 @@ async fn test_fake_melt_payment_error_unknown() -> Result<()> { let mint_quote = wallet.mint_quote(100.into(), None).await?; - sleep(Duration::from_millis(5)).await; + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet .mint(&mint_quote.id, SplitTarget::default(), None) @@ -296,7 +301,7 @@ async fn test_fake_melt_payment_err_paid() -> Result<()> { let mint_quote = wallet.mint_quote(100.into(), None).await?; - sleep(Duration::from_millis(5)).await; + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet .mint(&mint_quote.id, SplitTarget::default(), None) @@ -334,7 +339,7 @@ async fn test_fake_melt_change_in_quote() -> Result<()> { let mint_quote = wallet.mint_quote(100.into(), None).await?; - sleep(Duration::from_millis(5)).await; + wait_for_mint_to_be_paid(&wallet, &mint_quote.id).await?; let _mint_amount = wallet .mint(&mint_quote.id, SplitTarget::default(), None) @@ -344,7 +349,7 @@ async fn test_fake_melt_change_in_quote() -> Result<()> { let invoice = create_fake_invoice(9000, serde_json::to_string(&fake_description).unwrap()); - let proofs = wallet.get_proofs().await?; + let proofs = wallet.get_unspent_proofs().await?; let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?; @@ -354,28 +359,35 @@ async fn test_fake_melt_change_in_quote() -> Result<()> { let client = HttpClient::new(); - let melt_response = client - .post_melt( - MINT_URL.parse()?, - melt_quote.id.clone(), - proofs.clone(), - Some(premint_secrets.blinded_messages()), - ) - .await?; + let melt_request = MeltBolt11Request { + quote: melt_quote.id.clone(), + inputs: proofs.clone(), + outputs: Some(premint_secrets.blinded_messages()), + }; + + let melt_response = client.post_melt(MINT_URL.parse()?, melt_request).await?; assert!(melt_response.change.is_some()); let check = wallet.melt_quote_status(&melt_quote.id).await?; + let mut melt_change = melt_response.change.unwrap(); + melt_change.sort_by(|a, b| a.amount.cmp(&b.amount)); + + let mut check = check.change.unwrap(); + check.sort_by(|a, b| a.amount.cmp(&b.amount)); - assert_eq!( - melt_response - .change - .unwrap() - .sort_by(|a, b| a.amount.cmp(&b.amount)), - check - .change - .unwrap() - .sort_by(|a, b| a.amount.cmp(&b.amount)) - ); + assert_eq!(melt_change, check); Ok(()) } + +// Keep polling the state of the mint quote id until it's paid +async fn wait_for_mint_to_be_paid(wallet: &Wallet, mint_quote_id: &str) -> Result<()> { + loop { + let status = wallet.mint_quote_state(mint_quote_id).await?; + if status.state == MintQuoteState::Paid { + return Ok(()); + } + + sleep(Duration::from_millis(5)).await; + } +} diff --git a/crates/cdk-integration-tests/tests/mint.rs b/crates/cdk-integration-tests/tests/mint.rs index c86e2dd3..b75f0378 100644 --- a/crates/cdk-integration-tests/tests/mint.rs +++ b/crates/cdk-integration-tests/tests/mint.rs @@ -6,16 +6,20 @@ use cdk::amount::{Amount, SplitTarget}; use cdk::cdk_database::mint_memory::MintMemoryDatabase; use cdk::dhke::construct_proofs; use cdk::mint::MintQuote; +use cdk::nuts::nut00::ProofsMethods; +use cdk::nuts::nut17::Params; use cdk::nuts::{ - CurrencyUnit, Id, MintBolt11Request, MintInfo, Nuts, PreMintSecrets, Proofs, SecretKey, - SpendingConditions, SwapRequest, + CurrencyUnit, Id, MintBolt11Request, MintInfo, NotificationPayload, Nuts, PreMintSecrets, + ProofState, Proofs, SecretKey, SpendingConditions, State, SwapRequest, }; use cdk::types::QuoteTTL; use cdk::util::unix_time; use cdk::Mint; use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use tokio::sync::OnceCell; +use tokio::time::sleep; pub const MINT_URL: &str = "http://127.0.0.1:8088"; @@ -48,6 +52,7 @@ async fn new_mint(fee: u64) -> Mint { Arc::new(MintMemoryDatabase::default()), HashMap::new(), supported_units, + HashMap::new(), ) .await .unwrap() @@ -206,6 +211,31 @@ pub async fn test_p2pk_swap() -> Result<()> { let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages()); + let public_keys_to_listen: Vec<_> = swap_request + .inputs + .ys() + .expect("key") + .into_iter() + .enumerate() + .filter_map(|(key, pk)| { + if key % 2 == 0 { + // Only expect messages from every other key + Some(pk.to_string()) + } else { + None + } + }) + .collect(); + + let mut listener = mint + .pubsub_manager + .subscribe(Params { + kind: cdk::nuts::nut17::Kind::ProofState, + filters: public_keys_to_listen.clone(), + id: "test".into(), + }) + .await; + match mint.process_swap_request(swap_request).await { Ok(_) => bail!("Proofs spent without sig"), Err(err) => match err { @@ -227,6 +257,34 @@ pub async fn test_p2pk_swap() -> Result<()> { assert!(attempt_swap.is_ok()); + sleep(Duration::from_millis(10)).await; + + let mut msgs = HashMap::new(); + while let Ok((sub_id, msg)) = listener.try_recv() { + assert_eq!(sub_id, "test".into()); + match msg { + NotificationPayload::ProofState(ProofState { y, state, .. }) => { + let pk = y.to_string(); + msgs.get_mut(&pk) + .map(|x: &mut Vec| { + x.push(state); + }) + .unwrap_or_else(|| { + msgs.insert(pk, vec![state]); + }); + } + _ => bail!("Wrong message received"), + } + } + + for keys in public_keys_to_listen { + let statuses = msgs.remove(&keys).expect("some events"); + assert_eq!(statuses, vec![State::Pending, State::Pending, State::Spent]); + } + + assert!(listener.try_recv().is_err(), "no other event is happening"); + assert!(msgs.is_empty(), "Only expected key events are received"); + Ok(()) } @@ -270,7 +328,8 @@ async fn test_swap_unbalanced() -> Result<()> { async fn test_swap_overpay_underpay_fee() -> Result<()> { let mint = new_mint(1).await; - mint.rotate_keyset(CurrencyUnit::Sat, 1, 32, 1).await?; + mint.rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, HashMap::new()) + .await?; let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys; let keyset_id = Id::from(&keys); diff --git a/crates/cdk-integration-tests/tests/regtest.rs b/crates/cdk-integration-tests/tests/regtest.rs index 5658fb57..24bbe89b 100644 --- a/crates/cdk-integration-tests/tests/regtest.rs +++ b/crates/cdk-integration-tests/tests/regtest.rs @@ -1,17 +1,60 @@ -use std::{str::FromStr, sync::Arc, time::Duration}; +use std::{fmt::Debug, str::FromStr, sync::Arc, time::Duration}; use anyhow::{bail, Result}; use bip39::Mnemonic; use cdk::{ amount::{Amount, SplitTarget}, cdk_database::WalletMemoryDatabase, - nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, PreMintSecrets, State}, - wallet::{client::HttpClient, Wallet}, + nuts::{ + CurrencyUnit, MeltQuoteState, MintBolt11Request, MintQuoteState, NotificationPayload, + PreMintSecrets, State, + }, + wallet::{ + client::{HttpClient, HttpClientMethods}, + Wallet, + }, }; -use cdk_integration_tests::init_regtest::{get_mint_url, init_cln_client, init_lnd_client}; +use cdk_integration_tests::init_regtest::{ + get_mint_url, get_mint_ws_url, init_cln_client, init_lnd_client, +}; +use futures::{SinkExt, StreamExt}; use lightning_invoice::Bolt11Invoice; use ln_regtest_rs::InvoiceStatus; -use tokio::time::sleep; +use serde_json::json; +use tokio::time::{sleep, timeout}; +use tokio_tungstenite::{connect_async, tungstenite::protocol::Message}; + +async fn get_notification> + Unpin, E: Debug>( + reader: &mut T, + timeout_to_wait: Duration, +) -> (String, NotificationPayload) { + let msg = timeout(timeout_to_wait, reader.next()) + .await + .expect("timeout") + .unwrap() + .unwrap(); + + let mut response: serde_json::Value = + serde_json::from_str(msg.to_text().unwrap()).expect("valid json"); + + let mut params_raw = response + .as_object_mut() + .expect("object") + .remove("params") + .expect("valid params"); + + let params_map = params_raw.as_object_mut().expect("params is object"); + + ( + params_map + .remove("subId") + .unwrap() + .as_str() + .unwrap() + .to_string(), + serde_json::from_value(params_map.remove("payload").unwrap()).unwrap(), + ) +} #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_regtest_mint_melt_round_trip() -> Result<()> { @@ -25,6 +68,11 @@ async fn test_regtest_mint_melt_round_trip() -> Result<()> { None, )?; + let (ws_stream, _) = connect_async(get_mint_ws_url()) + .await + .expect("Failed to connect"); + let (mut write, mut reader) = ws_stream.split(); + let mint_quote = wallet.mint_quote(100.into(), None).await?; lnd_client.pay_invoice(mint_quote.request).await?; @@ -39,11 +87,52 @@ async fn test_regtest_mint_melt_round_trip() -> Result<()> { let melt = wallet.melt_quote(invoice, None).await?; - let melt = wallet.melt(&melt.id).await.unwrap(); - - assert!(melt.preimage.is_some()); + write + .send(Message::Text(serde_json::to_string(&json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "subscribe", + "params": { + "kind": "bolt11_melt_quote", + "filters": [ + melt.id.clone(), + ], + "subId": "test-sub", + } + + }))?)) + .await?; - assert!(melt.state == MeltQuoteState::Paid); + assert_eq!( + reader.next().await.unwrap().unwrap().to_text().unwrap(), + r#"{"jsonrpc":"2.0","result":{"status":"OK","subId":"test-sub"},"id":2}"# + ); + + let melt_response = wallet.melt(&melt.id).await.unwrap(); + assert!(melt_response.preimage.is_some()); + assert!(melt_response.state == MeltQuoteState::Paid); + + let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await; + // first message is the current state + assert_eq!("test-sub", sub_id); + let payload = match payload { + NotificationPayload::MeltQuoteBolt11Response(melt) => melt, + _ => panic!("Wrong payload"), + }; + assert_eq!(payload.amount + payload.fee_reserve, 100.into()); + assert_eq!(payload.quote, melt.id); + assert_eq!(payload.state, MeltQuoteState::Unpaid); + + // get current state + let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await; + assert_eq!("test-sub", sub_id); + let payload = match payload { + NotificationPayload::MeltQuoteBolt11Response(melt) => melt, + _ => panic!("Wrong payload"), + }; + assert_eq!(payload.amount + payload.fee_reserve, 100.into()); + assert_eq!(payload.quote, melt.id); + assert_eq!(payload.state, MeltQuoteState::Paid); Ok(()) } @@ -111,7 +200,7 @@ async fn test_restore() -> Result<()> { assert!(wallet_2.total_balance().await? == 0.into()); let restored = wallet_2.restore().await?; - let proofs = wallet_2.get_proofs().await?; + let proofs = wallet_2.get_unspent_proofs().await?; wallet_2 .swap(None, SplitTarget::default(), proofs, None, false) @@ -121,7 +210,7 @@ async fn test_restore() -> Result<()> { assert!(wallet_2.total_balance().await? == 100.into()); - let proofs = wallet.get_proofs().await?; + let proofs = wallet.get_unspent_proofs().await?; let states = wallet.check_proofs_spent(proofs).await?; @@ -289,15 +378,16 @@ async fn test_cached_mint() -> Result<()> { let premint_secrets = PreMintSecrets::random(active_keyset_id, 31.into(), &SplitTarget::default()).unwrap(); + let request = MintBolt11Request { + quote: quote.id, + outputs: premint_secrets.blinded_messages(), + }; + let response = http_client - .post_mint( - get_mint_url().as_str().parse()?, - "e.id, - premint_secrets.clone(), - ) + .post_mint(get_mint_url().as_str().parse()?, request.clone()) .await?; let response1 = http_client - .post_mint(get_mint_url().as_str().parse()?, "e.id, premint_secrets) + .post_mint(get_mint_url().as_str().parse()?, request) .await?; assert!(response == response1); diff --git a/crates/cdk-lnbits/src/lib.rs b/crates/cdk-lnbits/src/lib.rs index 64e7bef7..f983847b 100644 --- a/crates/cdk-lnbits/src/lib.rs +++ b/crates/cdk-lnbits/src/lib.rs @@ -15,10 +15,7 @@ use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, }; use cdk::mint::FeeReserve; -use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, -}; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::unix_time; use cdk::{mint, Bolt11Invoice}; use error::Error; @@ -35,8 +32,6 @@ pub mod error; #[derive(Clone)] pub struct LNbits { lnbits_api: LNBitsClient, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, fee_reserve: FeeReserve, receiver: Arc>>>, webhook_url: String, @@ -51,8 +46,6 @@ impl LNbits { admin_api_key: String, invoice_api_key: String, api_url: String, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, fee_reserve: FeeReserve, receiver: Arc>>>, webhook_url: String, @@ -61,8 +54,6 @@ impl LNbits { Ok(Self { lnbits_api, - mint_settings, - melt_settings, receiver, fee_reserve, webhook_url, @@ -80,8 +71,6 @@ impl MintLightning for LNbits { Settings { mpp: false, unit: CurrencyUnit::Sat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, invoice_description: true, } } diff --git a/crates/cdk-lnd/src/lib.rs b/crates/cdk-lnd/src/lib.rs index c5ecbeb8..96ce2f4e 100644 --- a/crates/cdk-lnd/src/lib.rs +++ b/crates/cdk-lnd/src/lib.rs @@ -18,10 +18,7 @@ use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, }; use cdk::mint::FeeReserve; -use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, -}; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::{hex, unix_time}; use cdk::{mint, Bolt11Invoice}; use error::Error; @@ -43,8 +40,6 @@ pub struct Lnd { macaroon_file: PathBuf, client: Arc>, fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, } @@ -56,8 +51,6 @@ impl Lnd { cert_file: PathBuf, macaroon_file: PathBuf, fee_reserve: FeeReserve, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, ) -> Result { let client = fedimint_tonic_lnd::connect(address.to_string(), &cert_file, &macaroon_file) .await @@ -72,8 +65,6 @@ impl Lnd { macaroon_file, client: Arc::new(Mutex::new(client)), fee_reserve, - mint_settings, - melt_settings, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), }) @@ -88,8 +79,6 @@ impl MintLightning for Lnd { Settings { mpp: true, unit: CurrencyUnit::Msat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, invoice_description: true, } } diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index 34640215..b12372ef 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -36,6 +36,7 @@ home = "0.5.5" url = "2.3" utoipa = { version = "4", optional = true } utoipa-swagger-ui = { version = "4", features = ["axum"], optional = true } +rand = "0.8.5" [features] swagger = ["cdk-axum/swagger", "dep:utoipa", "dep:utoipa-swagger-ui"] \ No newline at end of file diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 5e94f69b..dc5daca5 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -35,12 +35,27 @@ pub enum LnBackend { Lnd, } -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ln { pub ln_backend: LnBackend, pub invoice_description: Option, - pub fee_percent: f32, - pub reserve_fee_min: Amount, + pub min_mint: Amount, + pub max_mint: Amount, + pub min_melt: Amount, + pub max_melt: Amount, +} + +impl Default for Ln { + fn default() -> Self { + Ln { + ln_backend: LnBackend::default(), + invoice_description: None, + min_mint: 1.into(), + max_mint: 500_000.into(), + min_melt: 1.into(), + max_melt: 500_000.into(), + } + } } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -54,11 +69,16 @@ pub struct LNbits { pub admin_api_key: String, pub invoice_api_key: String, pub lnbits_api: String, + pub fee_percent: f32, + pub reserve_fee_min: Amount, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Cln { pub rpc_path: PathBuf, + pub bolt12: bool, + pub fee_percent: f32, + pub reserve_fee_min: Amount, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -66,23 +86,36 @@ pub struct Lnd { pub address: String, pub cert_file: PathBuf, pub macaroon_file: PathBuf, + pub fee_percent: f32, + pub reserve_fee_min: Amount, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Phoenixd { pub api_password: String, pub api_url: String, + pub bolt12: bool, + pub fee_percent: f32, + pub reserve_fee_min: Amount, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FakeWallet { pub supported_units: Vec, + pub fee_percent: f32, + pub reserve_fee_min: Amount, + pub min_delay_time: u64, + pub max_delay_time: u64, } impl Default for FakeWallet { fn default() -> Self { Self { supported_units: vec![CurrencyUnit::Sat], + fee_percent: 0.02, + reserve_fee_min: 2.into(), + min_delay_time: 1, + max_delay_time: 3, } } } diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs new file mode 100644 index 00000000..9cc38519 --- /dev/null +++ b/crates/cdk-mintd/src/lib.rs @@ -0,0 +1,22 @@ +//! Cdk mintd lib + +use std::path::PathBuf; + +pub mod cli; +pub mod config; +pub mod setup; + +fn expand_path(path: &str) -> Option { + if path.starts_with('~') { + if let Some(home_dir) = home::home_dir().as_mut() { + let remainder = &path[2..]; + home_dir.push(remainder); + let expanded_path = home_dir; + Some(expanded_path.clone()) + } else { + None + } + } else { + Some(PathBuf::from(path)) + } +} diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 4adc2809..7472bdfb 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -3,7 +3,7 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -14,33 +14,22 @@ use bip39::Mnemonic; use cdk::cdk_database::{self, MintDatabase}; use cdk::cdk_lightning; use cdk::cdk_lightning::MintLightning; -use cdk::mint::{FeeReserve, MeltQuote, Mint}; -use cdk::mint_url::MintUrl; -use cdk::nuts::{ - nut04, nut05, ContactInfo, CurrencyUnit, MeltMethodSettings, MeltQuoteState, MintInfo, - MintMethodSettings, MintVersion, MppMethodSettings, Nuts, PaymentMethod, -}; -use cdk::types::{LnKey, QuoteTTL}; -use cdk_cln::Cln; -use cdk_fake_wallet::FakeWallet; -use cdk_lnbits::LNbits; -use cdk_lnd::Lnd; -use cdk_phoenixd::Phoenixd; +use cdk::mint::{MeltQuote, Mint}; +use cdk::mint::{MintBuilder, MintMeltLimits}; +use cdk::nuts::{ContactInfo, CurrencyUnit, MeltQuoteState, MintVersion, PaymentMethod}; +use cdk::types::LnKey; +use cdk_mintd::setup::LnBackendSetup; use cdk_redb::MintRedbDatabase; use cdk_sqlite::MintSqliteDatabase; -use cdk_strike::Strike; use clap::Parser; -use cli::CLIArgs; -use config::{DatabaseEngine, LnBackend}; -use tokio::sync::{Mutex, Notify}; +use tokio::sync::Notify; use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; -use url::Url; #[cfg(feature = "swagger")] use utoipa::OpenApi; -mod cli; -mod config; +use cdk_mintd::cli::CLIArgs; +use cdk_mintd::config::{self, DatabaseEngine, LnBackend}; const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); const DEFAULT_QUOTE_TTL_SECS: u64 = 1800; @@ -74,6 +63,8 @@ async fn main() -> anyhow::Result<()> { None => work_dir.join("config.toml"), }; + let mut mint_builder = MintBuilder::new(); + let settings = config::Settings::new(&Some(config_file_arg)); let localstore: Arc + Send + Sync> = @@ -92,6 +83,8 @@ async fn main() -> anyhow::Result<()> { } }; + mint_builder = mint_builder.with_localstore(localstore); + let mut contact_info: Option> = None; if let Some(nostr_contact) = &settings.mint_info.contact_nostr_public_key { @@ -123,323 +116,155 @@ async fn main() -> anyhow::Result<()> { CARGO_PKG_VERSION.unwrap_or("Unknown").to_string(), ); - let relative_ln_fee = settings.ln.fee_percent; - - let absolute_ln_fee_reserve = settings.ln.reserve_fee_min; - - let fee_reserve = FeeReserve { - min_fee_reserve: absolute_ln_fee_reserve, - percent_fee_reserve: relative_ln_fee, - }; - let mut ln_backends: HashMap< LnKey, Arc + Send + Sync>, > = HashMap::new(); + let mut ln_routers = vec![]; - let mut supported_units = HashMap::new(); - let input_fee_ppk = settings.info.input_fee_ppk.unwrap_or(0); - - let mint_url: MintUrl = settings.info.url.parse()?; + let mint_melt_limits = MintMeltLimits { + mint_min: settings.ln.min_mint, + mint_max: settings.ln.max_mint, + melt_min: settings.ln.min_melt, + melt_max: settings.ln.max_melt, + }; - let ln_routers: Vec = match settings.ln.ln_backend { + match settings.ln.ln_backend { LnBackend::Cln => { - let cln_socket = expand_path( - settings - .cln - .expect("Config checked at load that cln is some") - .rpc_path - .to_str() - .ok_or(anyhow!("cln socket not defined"))?, - ) - .ok_or(anyhow!("cln socket not defined"))?; - let cln = Arc::new( - Cln::new( - cln_socket, - fee_reserve, - MintMethodSettings::default(), - MeltMethodSettings::default(), - ) - .await?, - ); + let cln_settings = settings + .cln + .clone() + .expect("Config checked at load that cln is some"); - ln_backends.insert(LnKey::new(CurrencyUnit::Sat, PaymentMethod::Bolt11), cln); - supported_units.insert(CurrencyUnit::Sat, (input_fee_ppk, 64)); - vec![] + let cln = cln_settings + .setup(&mut ln_routers, &settings, CurrencyUnit::Msat) + .await?; + let cln = Arc::new(cln); + let ln_key = LnKey { + unit: CurrencyUnit::Sat, + method: PaymentMethod::Bolt11, + }; + ln_backends.insert(ln_key, cln.clone()); + + mint_builder = mint_builder.add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + mint_melt_limits, + cln.clone(), + ); } LnBackend::Strike => { - let strike_settings = settings.strike.expect("Checked on config load"); - let api_key = strike_settings.api_key; + let strike_settings = settings.clone().strike.expect("Checked on config load"); - let units = strike_settings + for unit in strike_settings + .clone() .supported_units - .unwrap_or(vec![CurrencyUnit::Sat]); - - let mut routers = vec![]; - - for unit in units { - // Channel used for strike web hook - let (sender, receiver) = tokio::sync::mpsc::channel(8); - let webhook_endpoint = format!("/webhook/{}/invoice", unit); - - let webhook_url = mint_url.join(&webhook_endpoint)?; - - let strike = Strike::new( - api_key.clone(), - MintMethodSettings::default(), - MeltMethodSettings::default(), - unit, - Arc::new(Mutex::new(Some(receiver))), - webhook_url.to_string(), - ) - .await?; - - let router = strike - .create_invoice_webhook(&webhook_endpoint, sender) + .unwrap_or(vec![CurrencyUnit::Sat]) + { + let strike = strike_settings + .setup(&mut ln_routers, &settings, unit.clone()) .await?; - routers.push(router); - - let ln_key = LnKey::new(unit, PaymentMethod::Bolt11); - - ln_backends.insert(ln_key, Arc::new(strike)); - supported_units.insert(unit, (input_fee_ppk, 64)); + mint_builder = mint_builder.add_ln_backend( + unit, + PaymentMethod::Bolt11, + mint_melt_limits, + Arc::new(strike), + ); } - - routers } LnBackend::LNbits => { - let lnbits_settings = settings.lnbits.expect("Checked on config load"); - let admin_api_key = lnbits_settings.admin_api_key; - let invoice_api_key = lnbits_settings.invoice_api_key; - - // Channel used for lnbits web hook - let (sender, receiver) = tokio::sync::mpsc::channel(8); - let webhook_endpoint = "/webhook/lnbits/sat/invoice"; - - let webhook_url = mint_url.join(webhook_endpoint)?; - - let lnbits = LNbits::new( - admin_api_key, - invoice_api_key, - lnbits_settings.lnbits_api, - MintMethodSettings::default(), - MeltMethodSettings::default(), - fee_reserve, - Arc::new(Mutex::new(Some(receiver))), - webhook_url.to_string(), - ) - .await?; - - let router = lnbits - .create_invoice_webhook_router(webhook_endpoint, sender) + let lnbits_settings = settings.clone().lnbits.expect("Checked on config load"); + let lnbits = lnbits_settings + .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) .await?; - let unit = CurrencyUnit::Sat; - - let ln_key = LnKey::new(unit, PaymentMethod::Bolt11); - - ln_backends.insert(ln_key, Arc::new(lnbits)); - - supported_units.insert(unit, (input_fee_ppk, 64)); - vec![router] + mint_builder = mint_builder.add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + mint_melt_limits, + Arc::new(lnbits), + ); } LnBackend::Phoenixd => { - let api_password = settings - .clone() - .phoenixd - .expect("Checked at config load") - .api_password; - - let api_url = settings - .clone() - .phoenixd - .expect("Checked at config load") - .api_url; - - if fee_reserve.percent_fee_reserve < 0.04 { - bail!("Fee reserve is too low needs to be at least 0.02"); - } - - let webhook_endpoint = "/webhook/phoenixd"; - - let mint_url = Url::parse(&settings.info.url)?; - - let webhook_url = mint_url.join(webhook_endpoint)?.to_string(); - - let (sender, receiver) = tokio::sync::mpsc::channel(8); - - let phoenixd = Phoenixd::new( - api_password.to_string(), - api_url.to_string(), - MintMethodSettings::default(), - MeltMethodSettings::default(), - fee_reserve, - Arc::new(Mutex::new(Some(receiver))), - webhook_url, - )?; - - let router = phoenixd - .create_invoice_webhook(webhook_endpoint, sender) + let phd_settings = settings.clone().phoenixd.expect("Checked at config load"); + let phd = phd_settings + .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) .await?; - supported_units.insert(CurrencyUnit::Sat, (input_fee_ppk, 64)); - ln_backends.insert( - LnKey { - unit: CurrencyUnit::Sat, - method: PaymentMethod::Bolt11, - }, - Arc::new(phoenixd), + mint_builder = mint_builder.add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + mint_melt_limits, + Arc::new(phd), ); - - vec![router] } LnBackend::Lnd => { - let lnd_settings = settings.lnd.expect("Checked at config load"); - - let address = lnd_settings.address; - let cert_file = lnd_settings.cert_file; - let macaroon_file = lnd_settings.macaroon_file; - - let lnd = Lnd::new( - address, - cert_file, - macaroon_file, - fee_reserve, - MintMethodSettings::default(), - MeltMethodSettings::default(), - ) - .await?; + let lnd_settings = settings.clone().lnd.expect("Checked at config load"); + let lnd = lnd_settings + .setup(&mut ln_routers, &settings, CurrencyUnit::Msat) + .await?; - supported_units.insert(CurrencyUnit::Sat, (input_fee_ppk, 64)); - ln_backends.insert( - LnKey { - unit: CurrencyUnit::Sat, - method: PaymentMethod::Bolt11, - }, + mint_builder = mint_builder.add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + mint_melt_limits, Arc::new(lnd), ); - - vec![] } LnBackend::FakeWallet => { - let units = settings.fake_wallet.unwrap_or_default().supported_units; + let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); - for unit in units { - let ln_key = LnKey::new(unit, PaymentMethod::Bolt11); - - let wallet = Arc::new(FakeWallet::new( - fee_reserve.clone(), - MintMethodSettings::default(), - MeltMethodSettings::default(), - HashMap::default(), - HashSet::default(), - 0, - )); + for unit in fake_wallet.clone().supported_units { + let fake = fake_wallet + .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) + .await?; - ln_backends.insert(ln_key, wallet); + let fake = Arc::new(fake); - supported_units.insert(unit, (input_fee_ppk, 64)); + mint_builder = mint_builder.add_ln_backend( + unit.clone(), + PaymentMethod::Bolt11, + mint_melt_limits, + fake.clone(), + ); } - - vec![] } }; - let (nut04_settings, nut05_settings, mpp_settings): ( - nut04::Settings, - nut05::Settings, - Vec, - ) = ln_backends.iter().fold( - ( - nut04::Settings::new(vec![], false), - nut05::Settings::new(vec![], false), - Vec::new(), - ), - |(mut nut_04, mut nut_05, mut mpp), (key, ln)| { - let settings = ln.get_settings(); - - let m = MppMethodSettings { - method: key.method, - unit: key.unit, - mpp: settings.mpp, - }; - - let n4 = MintMethodSettings { - method: key.method, - unit: key.unit, - min_amount: settings.mint_settings.min_amount, - max_amount: settings.mint_settings.max_amount, - description: settings.invoice_description, - }; - - let n5 = MeltMethodSettings { - method: key.method, - unit: key.unit, - min_amount: settings.melt_settings.min_amount, - max_amount: settings.melt_settings.max_amount, - }; - - nut_04.methods.push(n4); - nut_05.methods.push(n5); - mpp.push(m); - - (nut_04, nut_05, mpp) - }, - ); - - let nuts = Nuts::new() - .nut04(nut04_settings) - .nut05(nut05_settings) - .nut07(true) - .nut08(true) - .nut09(true) - .nut10(true) - .nut11(true) - .nut12(true) - .nut14(true) - .nut15(mpp_settings); - - let mut mint_info = MintInfo::new() - .name(settings.mint_info.name) - .version(mint_version) - .description(settings.mint_info.description) - .nuts(nuts); - if let Some(long_description) = &settings.mint_info.description_long { - mint_info = mint_info.long_description(long_description); + mint_builder = mint_builder.with_long_description(long_description.to_string()); } if let Some(contact_info) = contact_info { - mint_info = mint_info.contact_info(contact_info); + for info in contact_info { + mint_builder = mint_builder.add_contact_info(info); + } } if let Some(pubkey) = settings.mint_info.pubkey { - mint_info = mint_info.pubkey(pubkey); + mint_builder = mint_builder.with_pubkey(pubkey); } if let Some(icon_url) = &settings.mint_info.icon_url { - mint_info = mint_info.icon_url(icon_url); + mint_builder = mint_builder.with_icon_url(icon_url.to_string()); } if let Some(motd) = settings.mint_info.motd { - mint_info = mint_info.motd(motd); + mint_builder = mint_builder.with_motd(motd); } let mnemonic = Mnemonic::from_str(&settings.info.mnemonic)?; - let quote_ttl = QuoteTTL::new(10000, 10000); + mint_builder = mint_builder + .with_name(settings.mint_info.name) + .with_mint_url(settings.info.url) + .with_version(mint_version) + .with_description(settings.mint_info.description) + .with_quote_ttl(10000, 10000) + .with_seed(mnemonic.to_seed_normalized("").to_vec()); - let mint = Mint::new( - &settings.info.url, - &mnemonic.to_seed_normalized(""), - mint_info, - quote_ttl, - localstore, - ln_backends.clone(), - supported_units, - ) - .await?; + let mint = mint_builder.build().await?; let mint = Arc::new(mint); @@ -529,22 +354,23 @@ async fn check_pending_mint_quotes( ln: Arc + Send + Sync>, ) -> Result<()> { let mut pending_quotes = mint.get_pending_mint_quotes().await?; - tracing::trace!("There are {} pending mint quotes.", pending_quotes.len()); + tracing::info!("There are {} pending mint quotes.", pending_quotes.len()); let mut unpaid_quotes = mint.get_unpaid_mint_quotes().await?; - tracing::trace!("There are {} unpaid mint quotes.", unpaid_quotes.len()); + tracing::info!("There are {} unpaid mint quotes.", unpaid_quotes.len()); unpaid_quotes.append(&mut pending_quotes); for quote in unpaid_quotes { - tracing::trace!("Checking status of mint quote: {}", quote.id); - let lookup_id = quote.request_lookup_id; - match ln.check_incoming_invoice_status(&lookup_id).await { + tracing::debug!("Checking status of mint quote: {}", quote.id); + let lookup_id = quote.request_lookup_id.as_str(); + match ln.check_incoming_invoice_status(lookup_id).await { Ok(state) => { if state != quote.state { tracing::trace!("Mint quote status changed: {}", quote.id); mint.localstore .update_mint_quote_state("e.id, state) .await?; + mint.pubsub_manager.mint_quote_bolt11_status(quote, state); } } @@ -567,8 +393,10 @@ async fn check_pending_melt_quotes( .into_iter() .filter(|q| q.state == MeltQuoteState::Pending || q.state == MeltQuoteState::Unknown) .collect(); + tracing::info!("There are {} pending melt quotes.", pending_quotes.len()); for pending_quote in pending_quotes { + tracing::debug!("Checking status for melt quote {}.", pending_quote.id); let melt_request_ln_key = mint.localstore.get_melt_request(&pending_quote.id).await?; let (melt_request, ln_key) = match melt_request_ln_key { @@ -645,21 +473,6 @@ async fn check_pending_melt_quotes( Ok(()) } -fn expand_path(path: &str) -> Option { - if path.starts_with('~') { - if let Some(home_dir) = home::home_dir().as_mut() { - let remainder = &path[2..]; - home_dir.push(remainder); - let expanded_path = home_dir; - Some(expanded_path.clone()) - } else { - None - } - } else { - Some(PathBuf::from(path)) - } -} - fn work_dir() -> Result { let home_dir = home::home_dir().ok_or(anyhow!("Unknown home dir"))?; diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs new file mode 100644 index 00000000..63674288 --- /dev/null +++ b/crates/cdk-mintd/src/setup.rs @@ -0,0 +1,234 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use anyhow::{anyhow, bail}; +use axum::{async_trait, Router}; +use rand::Rng; + +use cdk::{cdk_lightning::MintLightning, mint::FeeReserve, mint_url::MintUrl, nuts::CurrencyUnit}; +use tokio::sync::Mutex; +use url::Url; + +use crate::{ + config::{self, Settings}, + expand_path, +}; + +#[async_trait] +pub trait LnBackendSetup { + async fn setup( + &self, + routers: &mut Vec, + settings: &Settings, + unit: CurrencyUnit, + ) -> anyhow::Result; +} + +#[async_trait] +impl LnBackendSetup for config::Cln { + async fn setup( + &self, + _routers: &mut Vec, + _settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let cln_socket = expand_path( + self.rpc_path + .to_str() + .ok_or(anyhow!("cln socket not defined"))?, + ) + .ok_or(anyhow!("cln socket not defined"))?; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + let cln = cdk_cln::Cln::new(cln_socket, fee_reserve).await?; + + Ok(cln) + } +} + +#[async_trait] +impl LnBackendSetup for config::Strike { + async fn setup( + &self, + routers: &mut Vec, + settings: &Settings, + unit: CurrencyUnit, + ) -> anyhow::Result { + let api_key = &self.api_key; + + // Channel used for strike web hook + let (sender, receiver) = tokio::sync::mpsc::channel(8); + let webhook_endpoint = format!("/webhook/{}/invoice", unit); + + let mint_url: MintUrl = settings.info.url.parse()?; + let webhook_url = mint_url.join(&webhook_endpoint)?; + + let strike = cdk_strike::Strike::new( + api_key.clone(), + unit, + Arc::new(Mutex::new(Some(receiver))), + webhook_url.to_string(), + ) + .await?; + + let router = strike + .create_invoice_webhook(&webhook_endpoint, sender) + .await?; + routers.push(router); + + Ok(strike) + } +} + +#[async_trait] +impl LnBackendSetup for config::LNbits { + async fn setup( + &self, + routers: &mut Vec, + settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let admin_api_key = &self.admin_api_key; + let invoice_api_key = &self.invoice_api_key; + + // Channel used for lnbits web hook + let (sender, receiver) = tokio::sync::mpsc::channel(8); + let webhook_endpoint = "/webhook/lnbits/sat/invoice"; + + let mint_url: MintUrl = settings.info.url.parse()?; + let webhook_url = mint_url.join(webhook_endpoint)?; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + let lnbits = cdk_lnbits::LNbits::new( + admin_api_key.clone(), + invoice_api_key.clone(), + self.lnbits_api.clone(), + fee_reserve, + Arc::new(Mutex::new(Some(receiver))), + webhook_url.to_string(), + ) + .await?; + + let router = lnbits + .create_invoice_webhook_router(webhook_endpoint, sender) + .await?; + + routers.push(router); + + Ok(lnbits) + } +} + +#[async_trait] +impl LnBackendSetup for config::Phoenixd { + async fn setup( + &self, + routers: &mut Vec, + settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let api_password = &self.api_password; + + let api_url = &self.api_url; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + if fee_reserve.percent_fee_reserve < 0.04 { + bail!("Fee reserve is too low needs to be at least 0.02"); + } + + let webhook_endpoint = "/webhook/phoenixd"; + + let mint_url = Url::parse(&settings.info.url)?; + + let webhook_url = mint_url.join(webhook_endpoint)?.to_string(); + + let (sender, receiver) = tokio::sync::mpsc::channel(8); + + let phoenixd = cdk_phoenixd::Phoenixd::new( + api_password.to_string(), + api_url.to_string(), + fee_reserve, + Arc::new(Mutex::new(Some(receiver))), + webhook_url, + )?; + + let router = phoenixd + .create_invoice_webhook(webhook_endpoint, sender) + .await?; + + routers.push(router); + + Ok(phoenixd) + } +} + +#[async_trait] +impl LnBackendSetup for config::Lnd { + async fn setup( + &self, + _routers: &mut Vec, + _settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let address = &self.address; + let cert_file = &self.cert_file; + let macaroon_file = &self.macaroon_file; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + let lnd = cdk_lnd::Lnd::new( + address.to_string(), + cert_file.clone(), + macaroon_file.clone(), + fee_reserve, + ) + .await?; + + Ok(lnd) + } +} + +#[async_trait] +impl LnBackendSetup for config::FakeWallet { + async fn setup( + &self, + _router: &mut Vec, + _settings: &Settings, + _unit: CurrencyUnit, + ) -> anyhow::Result { + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + // calculate random delay time + let mut rng = rand::thread_rng(); + let delay_time = rng.gen_range(self.min_delay_time..=self.max_delay_time); + + let fake_wallet = cdk_fake_wallet::FakeWallet::new( + fee_reserve, + HashMap::default(), + HashSet::default(), + delay_time, + ); + + Ok(fake_wallet) + } +} diff --git a/crates/cdk-phoenixd/src/lib.rs b/crates/cdk-phoenixd/src/lib.rs index 4aecfac6..48d0912e 100644 --- a/crates/cdk-phoenixd/src/lib.rs +++ b/crates/cdk-phoenixd/src/lib.rs @@ -15,10 +15,7 @@ use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, }; use cdk::mint::FeeReserve; -use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, -}; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::{mint, Bolt11Invoice}; use error::Error; use futures::{Stream, StreamExt}; @@ -32,8 +29,6 @@ pub mod error; /// Phoenixd #[derive(Clone)] pub struct Phoenixd { - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, phoenixd_api: PhoenixdApi, fee_reserve: FeeReserve, receiver: Arc>>>, @@ -47,16 +42,12 @@ impl Phoenixd { pub fn new( api_password: String, api_url: String, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, fee_reserve: FeeReserve, receiver: Arc>>>, webhook_url: String, ) -> Result { let phoenixd = PhoenixdApi::new(&api_password, &api_url)?; Ok(Self { - mint_settings, - melt_settings, phoenixd_api: phoenixd, fee_reserve, receiver, @@ -86,8 +77,6 @@ impl MintLightning for Phoenixd { Settings { mpp: false, unit: CurrencyUnit::Sat, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, invoice_description: true, } } diff --git a/crates/cdk-sqlite/src/wallet/migrations/20241011125207_mint_urls.sql b/crates/cdk-sqlite/src/wallet/migrations/20241011125207_mint_urls.sql new file mode 100644 index 00000000..0be88e8f --- /dev/null +++ b/crates/cdk-sqlite/src/wallet/migrations/20241011125207_mint_urls.sql @@ -0,0 +1 @@ +ALTER TABLE mint ADD urls TEXT; diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index f45c6bb4..0decfac3 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -91,6 +91,7 @@ impl WalletDatabase for WalletSqliteDatabase { contact, nuts, icon_url, + urls, motd, time, ) = match mint_info { @@ -104,6 +105,7 @@ impl WalletDatabase for WalletSqliteDatabase { contact, nuts, icon_url, + urls, motd, time, } = mint_info; @@ -117,18 +119,21 @@ impl WalletDatabase for WalletSqliteDatabase { contact.map(|c| serde_json::to_string(&c).ok()), serde_json::to_string(&nuts).ok(), icon_url, + urls.map(|c| serde_json::to_string(&c).ok()), motd, time, ) } - None => (None, None, None, None, None, None, None, None, None, None), + None => ( + None, None, None, None, None, None, None, None, None, None, None, + ), }; sqlx::query( r#" INSERT OR REPLACE INTO mint -(mint_url, name, pubkey, version, description, description_long, contact, nuts, icon_url, motd, mint_time) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); +(mint_url, name, pubkey, version, description, description_long, contact, nuts, icon_url, urls, motd, mint_time) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); "#, ) .bind(mint_url.to_string()) @@ -140,6 +145,7 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); .bind(contact) .bind(nuts) .bind(icon_url) + .bind(urls) .bind(motd) .bind(time.map(|v| v as i64)) .execute(&self.pool) @@ -775,6 +781,7 @@ fn sqlite_row_to_mint_info(row: &SqliteRow) -> Result { let row_nuts: Option = row.try_get("nuts").map_err(Error::from)?; let icon_url: Option = row.try_get("icon_url").map_err(Error::from)?; let motd: Option = row.try_get("motd").map_err(Error::from)?; + let row_urls: Option = row.try_get("urls").map_err(Error::from)?; let time: Option = row.try_get("mint_time").map_err(Error::from)?; Ok(MintInfo { @@ -788,6 +795,7 @@ fn sqlite_row_to_mint_info(row: &SqliteRow) -> Result { .and_then(|n| serde_json::from_str(&n).ok()) .unwrap_or_default(), icon_url, + urls: row_urls.and_then(|c| serde_json::from_str(&c).ok()), motd, time: time.map(|t| t as u64), }) diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs index ac28a23e..316df769 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -14,10 +14,7 @@ use cdk::amount::Amount; use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, }; -use cdk::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, -}; +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use cdk::util::unix_time; use cdk::{mint, Bolt11Invoice}; use error::Error; @@ -37,8 +34,6 @@ pub mod error; #[derive(Clone)] pub struct Strike { strike_api: StrikeApi, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, unit: CurrencyUnit, receiver: Arc>>>, webhook_url: String, @@ -50,8 +45,6 @@ impl Strike { /// Create new [`Strike`] wallet pub async fn new( api_key: String, - mint_settings: MintMethodSettings, - melt_settings: MeltMethodSettings, unit: CurrencyUnit, receiver: Arc>>>, webhook_url: String, @@ -59,8 +52,6 @@ impl Strike { let strike = StrikeApi::new(&api_key, None)?; Ok(Self { strike_api: strike, - mint_settings, - melt_settings, receiver, unit, webhook_url, @@ -77,9 +68,7 @@ impl MintLightning for Strike { fn get_settings(&self) -> Settings { Settings { mpp: false, - unit: self.unit, - mint_settings: self.mint_settings, - melt_settings: self.melt_settings, + unit: self.unit.clone(), invoice_description: true, } } @@ -288,7 +277,7 @@ impl MintLightning for Strike { payment_preimage: None, status: state, total_spent: from_strike_amount(invoice.total_amount, &self.unit)?.into(), - unit: self.unit, + unit: self.unit.clone(), } } Err(err) => match err { @@ -297,7 +286,7 @@ impl MintLightning for Strike { payment_preimage: None, status: MeltQuoteState::Unknown, total_spent: Amount::ZERO, - unit: self.unit, + unit: self.unit.clone(), }, _ => { return Err(Error::from(err).into()); diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index b72e50d2..4d72da31 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -13,6 +13,7 @@ license = "MIT" [features] default = ["mint", "wallet"] mint = ["dep:futures"] +# We do not commit to a MSRV with swagger enabled swagger = ["mint", "dep:utoipa"] wallet = ["dep:reqwest"] bench = [] @@ -35,10 +36,10 @@ reqwest = { version = "0.12", default-features = false, features = [ ], optional = true } serde = { version = "1", default-features = false, features = ["derive"] } serde_json = "1" -serde_with = "3.1" +serde_with = "3" tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } thiserror = "1" -futures = { version = "0.3.28", default-features = false, optional = true } +futures = { version = "0.3.28", default-features = false, optional = true, features = ["alloc"] } url = "2.3" utoipa = { version = "4", optional = true } uuid = { version = "1", features = ["v4"] } diff --git a/crates/cdk/examples/proof-selection.rs b/crates/cdk/examples/proof-selection.rs index 46d687a2..210b7731 100644 --- a/crates/cdk/examples/proof-selection.rs +++ b/crates/cdk/examples/proof-selection.rs @@ -48,7 +48,7 @@ async fn main() { println!("Minted {}", receive_amount); } - let proofs = wallet.get_proofs().await.unwrap(); + let proofs = wallet.get_unspent_proofs().await.unwrap(); let selected = wallet .select_proofs_to_send(Amount::from(64), proofs, false) diff --git a/crates/cdk/src/cdk_lightning/mod.rs b/crates/cdk/src/cdk_lightning/mod.rs index eb94fb4b..1b9c31a6 100644 --- a/crates/cdk/src/cdk_lightning/mod.rs +++ b/crates/cdk/src/cdk_lightning/mod.rs @@ -8,10 +8,7 @@ use lightning_invoice::{Bolt11Invoice, ParseOrSemanticError}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::nuts::{ - CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, - MintQuoteState, -}; +use crate::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState}; use crate::{mint, Amount}; /// CDK Lightning Error @@ -148,10 +145,6 @@ pub struct PaymentQuoteResponse { pub struct Settings { /// MPP supported pub mpp: bool, - /// Min amount to mint - pub mint_settings: MintMethodSettings, - /// Max amount to mint - pub melt_settings: MeltMethodSettings, /// Base unit of backend pub unit: CurrencyUnit, /// Invoice Description supported diff --git a/crates/cdk/src/lib.rs b/crates/cdk/src/lib.rs index 3d243047..effb04f9 100644 --- a/crates/cdk/src/lib.rs +++ b/crates/cdk/src/lib.rs @@ -20,6 +20,8 @@ pub mod util; #[cfg(feature = "wallet")] pub mod wallet; +pub mod pub_sub; + pub mod fees; #[doc(hidden)] diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs new file mode 100644 index 00000000..07b6840e --- /dev/null +++ b/crates/cdk/src/mint/builder.rs @@ -0,0 +1,217 @@ +//! Mint Builder + +use std::{collections::HashMap, sync::Arc}; + +use anyhow::anyhow; + +use crate::{ + amount::Amount, + cdk_database::{self, MintDatabase}, + cdk_lightning::{self, MintLightning}, + mint::Mint, + nuts::{ + ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings, MintVersion, + MppMethodSettings, PaymentMethod, + }, + types::{LnKey, QuoteTTL}, +}; + +/// Cashu Mint +#[derive(Default)] +pub struct MintBuilder { + /// Mint Url + mint_url: Option, + /// Mint Info + mint_info: MintInfo, + /// Mint Storage backend + localstore: Option + Send + Sync>>, + /// Ln backends for mint + ln: Option + Send + Sync>>>, + seed: Option>, + quote_ttl: Option, + supported_units: HashMap, +} + +impl MintBuilder { + /// New mint builder + pub fn new() -> MintBuilder { + MintBuilder::default() + } + + /// Set localstore + pub fn with_localstore( + mut self, + localstore: Arc + Send + Sync>, + ) -> MintBuilder { + self.localstore = Some(localstore); + self + } + + /// Set mint url + pub fn with_mint_url(mut self, mint_url: String) -> Self { + self.mint_url = Some(mint_url); + self + } + + /// Set seed + pub fn with_seed(mut self, seed: Vec) -> Self { + self.seed = Some(seed); + self + } + + /// Set name + pub fn with_name(mut self, name: String) -> Self { + self.mint_info.name = Some(name); + self + } + + /// Set icon url + pub fn with_icon_url(mut self, icon_url: String) -> Self { + self.mint_info.icon_url = Some(icon_url); + self + } + + /// Set icon url + pub fn with_motd(mut self, motd: String) -> Self { + self.mint_info.motd = Some(motd); + self + } + + /// Set description + pub fn with_description(mut self, description: String) -> Self { + self.mint_info.description = Some(description); + self + } + + /// Set long description + pub fn with_long_description(mut self, description: String) -> Self { + self.mint_info.description_long = Some(description); + self + } + + /// Set version + pub fn with_version(mut self, version: MintVersion) -> Self { + self.mint_info.version = Some(version); + self + } + + /// Set contact info + pub fn add_contact_info(mut self, contact_info: ContactInfo) -> Self { + let mut contacts = self.mint_info.contact.clone().unwrap_or_default(); + contacts.push(contact_info); + self.mint_info.contact = Some(contacts); + self + } + + /// Add ln backend + pub fn add_ln_backend( + mut self, + unit: CurrencyUnit, + method: PaymentMethod, + limits: MintMeltLimits, + ln_backend: Arc + Send + Sync>, + ) -> Self { + let ln_key = LnKey { + unit: unit.clone(), + method, + }; + + let mut ln = self.ln.unwrap_or_default(); + + let settings = ln_backend.get_settings(); + + if settings.mpp { + let mpp_settings = MppMethodSettings { + method, + unit: unit.clone(), + mpp: true, + }; + let mut mpp = self.mint_info.nuts.nut15.clone(); + + mpp.methods.push(mpp_settings); + + self.mint_info.nuts.nut15 = mpp; + } + + match method { + PaymentMethod::Bolt11 => { + let mint_method_settings = MintMethodSettings { + method, + unit: unit.clone(), + min_amount: Some(limits.mint_min), + max_amount: Some(limits.mint_max), + description: settings.invoice_description, + }; + + self.mint_info.nuts.nut04.methods.push(mint_method_settings); + self.mint_info.nuts.nut04.disabled = false; + + let melt_method_settings = MeltMethodSettings { + method, + unit, + min_amount: Some(limits.melt_min), + max_amount: Some(limits.melt_max), + }; + self.mint_info.nuts.nut05.methods.push(melt_method_settings); + self.mint_info.nuts.nut05.disabled = false; + } + } + + ln.insert(ln_key.clone(), ln_backend); + + let mut supported_units = self.supported_units.clone(); + + supported_units.insert(ln_key.unit, (0, 32)); + self.supported_units = supported_units; + + self.ln = Some(ln); + + self + } + + /// Set quote ttl + pub fn with_quote_ttl(mut self, mint_ttl: u64, melt_ttl: u64) -> Self { + let quote_ttl = QuoteTTL { mint_ttl, melt_ttl }; + + self.quote_ttl = Some(quote_ttl); + + self + } + + /// Set pubkey + pub fn with_pubkey(mut self, pubkey: crate::nuts::PublicKey) -> Self { + self.mint_info.pubkey = Some(pubkey); + + self + } + + /// Build mint + pub async fn build(&self) -> anyhow::Result { + Ok(Mint::new( + self.mint_url.as_ref().ok_or(anyhow!("Mint url not set"))?, + self.seed.as_ref().ok_or(anyhow!("Mint seed not set"))?, + self.mint_info.clone(), + self.quote_ttl.ok_or(anyhow!("Quote ttl not set"))?, + self.localstore + .clone() + .ok_or(anyhow!("Localstore not set"))?, + self.ln.clone().ok_or(anyhow!("Ln backends not set"))?, + self.supported_units.clone(), + HashMap::new(), + ) + .await?) + } +} + +/// Mint Melt Limits +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct MintMeltLimits { + /// Min mint amount + pub mint_min: Amount, + /// Max mint amount + pub mint_max: Amount, + /// Min melt amount + pub melt_min: Amount, + /// Max melt amount + pub melt_max: Amount, +} diff --git a/crates/cdk/src/mint/check_spendable.rs b/crates/cdk/src/mint/check_spendable.rs index 7527abea..103bed2d 100644 --- a/crates/cdk/src/mint/check_spendable.rs +++ b/crates/cdk/src/mint/check_spendable.rs @@ -57,6 +57,10 @@ impl Mint { return Err(Error::TokenAlreadySpent); } + for public_key in ys { + self.pubsub_manager.proof_state((*public_key, proof_state)); + } + Ok(()) } } diff --git a/crates/cdk/src/mint/keysets.rs b/crates/cdk/src/mint/keysets.rs index 3c9642be..66445608 100644 --- a/crates/cdk/src/mint/keysets.rs +++ b/crates/cdk/src/mint/keysets.rs @@ -1,5 +1,6 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use bitcoin::bip32::DerivationPath; use tracing::instrument; use crate::Error; @@ -89,14 +90,20 @@ impl Mint { derivation_path_index: u32, max_order: u8, input_fee_ppk: u64, + custom_paths: HashMap, ) -> Result<(), Error> { - let derivation_path = derivation_path_from_unit(unit, derivation_path_index); + let derivation_path = match custom_paths.get(&unit) { + Some(path) => path.clone(), + None => derivation_path_from_unit(unit.clone(), derivation_path_index) + .ok_or(Error::UnsupportedUnit)?, + }; + let (keyset, keyset_info) = create_new_keyset( &self.secp_ctx, self.xpriv, derivation_path, Some(derivation_path_index), - unit, + unit.clone(), max_order, input_fee_ppk, ); diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index 53268b4d..1613fc3c 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -74,11 +74,11 @@ impl Mint { } }; - self.check_melt_request_acceptable(amount, *unit, PaymentMethod::Bolt11)?; + self.check_melt_request_acceptable(amount, unit.clone(), PaymentMethod::Bolt11)?; let ln = self .ln - .get(&LnKey::new(*unit, PaymentMethod::Bolt11)) + .get(&LnKey::new(unit.clone(), PaymentMethod::Bolt11)) .ok_or_else(|| { tracing::info!("Could not get ln backend for {}, bolt11 ", unit); @@ -97,7 +97,7 @@ impl Mint { let quote = MeltQuote::new( request.to_string(), - *unit, + unit.clone(), payment_quote.amount, payment_quote.fee, unix_time() + self.quote_ttl.melt_ttl, @@ -358,6 +358,16 @@ impl Mint { .update_melt_quote_state(&melt_request.quote, MeltQuoteState::Unpaid) .await?; + if let Ok(Some(quote)) = self.localstore.get_melt_quote(&melt_request.quote).await { + self.pubsub_manager + .melt_quote_status("e, None, None, MeltQuoteState::Unpaid); + } + + for public_key in input_ys { + self.pubsub_manager + .proof_state((public_key, State::Unspent)); + } + Ok(()) } @@ -447,7 +457,10 @@ impl Mint { } _ => None, }; - let ln = match self.ln.get(&LnKey::new(quote.unit, PaymentMethod::Bolt11)) { + let ln = match self + .ln + .get(&LnKey::new(quote.unit.clone(), PaymentMethod::Bolt11)) + { Some(ln) => ln, None => { tracing::info!("Could not get ln backend for {}, bolt11 ", quote.unit); @@ -595,6 +608,17 @@ impl Mint { .update_melt_quote_state(&melt_request.quote, MeltQuoteState::Paid) .await?; + self.pubsub_manager.melt_quote_status( + "e, + payment_preimage.clone(), + None, + MeltQuoteState::Paid, + ); + + for public_key in input_ys { + self.pubsub_manager.proof_state((public_key, State::Spent)); + } + let mut change = None; // Check if there is change to return diff --git a/crates/cdk/src/mint/mint_nut04.rs b/crates/cdk/src/mint/mint_nut04.rs index 7987ecac..fb0b6d4d 100644 --- a/crates/cdk/src/mint/mint_nut04.rs +++ b/crates/cdk/src/mint/mint_nut04.rs @@ -4,7 +4,7 @@ use crate::{nuts::MintQuoteState, types::LnKey, util::unix_time, Amount, Error}; use super::{ nut04, CurrencyUnit, Mint, MintQuote, MintQuoteBolt11Request, MintQuoteBolt11Response, - PaymentMethod, PublicKey, + NotificationPayload, PaymentMethod, PublicKey, }; impl Mint { @@ -12,7 +12,7 @@ impl Mint { fn check_mint_request_acceptable( &self, amount: Amount, - unit: CurrencyUnit, + unit: &CurrencyUnit, ) -> Result<(), Error> { let nut04 = &self.mint_info.nuts.nut04; @@ -20,7 +20,7 @@ impl Mint { return Err(Error::MintingDisabled); } - match nut04.get_settings(&unit, &PaymentMethod::Bolt11) { + match nut04.get_settings(unit, &PaymentMethod::Bolt11) { Some(settings) => { if settings .max_amount @@ -64,11 +64,11 @@ impl Mint { description, } = mint_quote_request; - self.check_mint_request_acceptable(amount, unit)?; + self.check_mint_request_acceptable(amount, &unit)?; let ln = self .ln - .get(&LnKey::new(unit, PaymentMethod::Bolt11)) + .get(&LnKey::new(unit.clone(), PaymentMethod::Bolt11)) .ok_or_else(|| { tracing::info!("Bolt11 mint request for unsupported unit"); @@ -98,7 +98,7 @@ impl Mint { let quote = MintQuote::new( self.mint_url.clone(), create_invoice_response.request.to_string(), - unit, + unit.clone(), amount, create_invoice_response.expiry.unwrap_or(0), create_invoice_response.request_lookup_id.clone(), @@ -114,7 +114,12 @@ impl Mint { self.localstore.add_mint_quote(quote.clone()).await?; - Ok(quote.into()) + let quote: MintQuoteBolt11Response = quote.into(); + + self.pubsub_manager + .broadcast(NotificationPayload::MintQuoteBolt11Response(quote.clone())); + + Ok(quote) } /// Check mint quote @@ -126,8 +131,6 @@ impl Mint { .await? .ok_or(Error::UnknownQuote)?; - let paid = quote.state == MintQuoteState::Paid; - // Since the pending state is not part of the NUT it should not be part of the // response. In practice the wallet should not be checking the state of // a quote while waiting for the mint response. @@ -139,7 +142,6 @@ impl Mint { Ok(MintQuoteBolt11Response { quote: quote.id, request: quote.request, - paid: Some(paid), state, expiry: Some(quote.expiry), }) @@ -201,13 +203,43 @@ impl Mint { .await { tracing::debug!( - "Quote {} paid by lookup id {}", - mint_quote.id, - request_lookup_id + "Received payment notification for mint quote {}", + mint_quote.id ); - self.localstore - .update_mint_quote_state(&mint_quote.id, MintQuoteState::Paid) - .await?; + if mint_quote.state != MintQuoteState::Issued + && mint_quote.state != MintQuoteState::Paid + { + let unix_time = unix_time(); + + if mint_quote.expiry < unix_time { + tracing::warn!( + "Mint quote {} paid at {} expired at {}, leaving current state", + mint_quote.id, + mint_quote.expiry, + unix_time, + ); + return Err(Error::ExpiredQuote(mint_quote.expiry, unix_time)); + } + + tracing::debug!( + "Marking quote {} paid by lookup id {}", + mint_quote.id, + request_lookup_id + ); + + self.localstore + .update_mint_quote_state(&mint_quote.id, MintQuoteState::Paid) + .await?; + } else { + tracing::debug!( + "{} Quote already {} continuing", + mint_quote.id, + mint_quote.state + ); + } + + self.pubsub_manager + .mint_quote_bolt11_status(mint_quote, MintQuoteState::Paid); } Ok(()) } @@ -218,13 +250,12 @@ impl Mint { &self, mint_request: nut04::MintBolt11Request, ) -> Result { - // Check quote is known - match self.localstore.get_mint_quote(&mint_request.quote).await? { - Some(_) => (), - None => { + let mint_quote = + if let Some(mint_quote) = self.localstore.get_mint_quote(&mint_request.quote).await? { + mint_quote + } else { return Err(Error::UnknownQuote); - } - } + }; let state = self .localstore @@ -269,6 +300,7 @@ impl Mint { .update_mint_quote_state(&mint_request.quote, MintQuoteState::Paid) .await .unwrap(); + return Err(Error::BlindedMessageAlreadySigned); } @@ -295,6 +327,9 @@ impl Mint { .update_mint_quote_state(&mint_request.quote, MintQuoteState::Issued) .await?; + self.pubsub_manager + .mint_quote_bolt11_status(mint_quote, MintQuoteState::Issued); + Ok(nut04::MintBolt11Response { signatures: blind_signatures, }) diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 0699cf7f..dbbdc59b 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -23,6 +23,7 @@ use crate::types::{LnKey, QuoteTTL}; use crate::util::unix_time; use crate::Amount; +mod builder; mod check_spendable; mod info; mod keysets; @@ -31,6 +32,7 @@ mod mint_nut04; mod swap; pub mod types; +pub use builder::{MintBuilder, MintMeltLimits}; pub use types::{MeltQuote, MintQuote}; /// Cashu Mint @@ -46,6 +48,8 @@ pub struct Mint { pub localstore: Arc + Send + Sync>, /// Ln backends for mint pub ln: HashMap + Send + Sync>>, + /// Subscription manager + pub pubsub_manager: Arc, /// Active Mint Keysets keysets: Arc>>, secp_ctx: Secp256k1, @@ -54,6 +58,7 @@ pub struct Mint { impl Mint { /// Create new [`Mint`] + #[allow(clippy::too_many_arguments)] pub async fn new( mint_url: &str, seed: &[u8], @@ -63,6 +68,7 @@ impl Mint { ln: HashMap + Send + Sync>>, // Hashmap where the key is the unit and value is (input fee ppk, max_order) supported_units: HashMap, + custom_paths: HashMap, ) -> Result { let secp_ctx = Secp256k1::new(); let xpriv = Xpriv::new_master(bitcoin::Network::Bitcoin, seed).expect("RNG busted"); @@ -83,7 +89,7 @@ impl Mint { let keysets_by_unit: HashMap> = keysets_infos.iter().fold(HashMap::new(), |mut acc, ks| { - acc.entry(ks.unit).or_default().push(ks.clone()); + acc.entry(ks.unit.clone()).or_default().push(ks.clone()); acc }); @@ -112,7 +118,7 @@ impl Mint { &secp_ctx, xpriv, highest_index_keyset.max_order, - highest_index_keyset.unit, + highest_index_keyset.unit.clone(), highest_index_keyset.derivation_path.clone(), ); active_keysets.insert(id, keyset); @@ -125,37 +131,46 @@ impl Mint { highest_index_keyset.derivation_path_index.unwrap_or(0) + 1 }; - let derivation_path = derivation_path_from_unit(unit, derivation_path_index); + let derivation_path = match custom_paths.get(&unit) { + Some(path) => path.clone(), + None => derivation_path_from_unit(unit.clone(), derivation_path_index) + .ok_or(Error::UnsupportedUnit)?, + }; let (keyset, keyset_info) = create_new_keyset( &secp_ctx, xpriv, derivation_path, Some(derivation_path_index), - unit, + unit.clone(), *max_order, *input_fee_ppk, ); let id = keyset_info.id; localstore.add_keyset_info(keyset_info).await?; - localstore.set_active_keyset(unit, id).await?; + localstore.set_active_keyset(unit.clone(), id).await?; active_keysets.insert(id, keyset); - active_keyset_units.push(unit); + active_keyset_units.push(unit.clone()); } } } for (unit, (fee, max_order)) in supported_units { if !active_keyset_units.contains(&unit) { - let derivation_path = derivation_path_from_unit(unit, 0); + let derivation_path = match custom_paths.get(&unit) { + Some(path) => path.clone(), + None => { + derivation_path_from_unit(unit.clone(), 0).ok_or(Error::UnsupportedUnit)? + } + }; let (keyset, keyset_info) = create_new_keyset( &secp_ctx, xpriv, derivation_path, Some(0), - unit, + unit.clone(), max_order, fee, ); @@ -170,6 +185,7 @@ impl Mint { Ok(Self { mint_url: MintUrl::from_str(mint_url)?, keysets: Arc::new(RwLock::new(active_keysets)), + pubsub_manager: Arc::new(localstore.clone().into()), secp_ctx, quote_ttl, xpriv, @@ -194,7 +210,7 @@ impl Mint { let mint = Arc::clone(&mint_arc); let ln = Arc::clone(ln); let shutdown = Arc::clone(&shutdown); - let key = *key; + let key = key.clone(); join_set.spawn(async move { if !ln.is_wait_invoice_active() { loop { @@ -438,7 +454,8 @@ impl Mint { Ok(RestoreResponse { outputs, - signatures, + signatures: signatures.clone(), + promises: Some(signatures), }) } @@ -559,7 +576,7 @@ fn create_new_keyset( ); let keyset_info = MintKeySetInfo { id: keyset.id, - unit: keyset.unit, + unit: keyset.unit.clone(), active: true, valid_from: unix_time(), valid_to: None, @@ -571,12 +588,17 @@ fn create_new_keyset( (keyset, keyset_info) } -fn derivation_path_from_unit(unit: CurrencyUnit, index: u32) -> DerivationPath { - DerivationPath::from(vec![ +fn derivation_path_from_unit(unit: CurrencyUnit, index: u32) -> Option { + let unit_index = match unit.derivation_index() { + Some(index) => index, + None => return None, + }; + + Some(DerivationPath::from(vec![ ChildNumber::from_hardened_idx(0).expect("0 is a valid index"), - ChildNumber::from_hardened_idx(unit.derivation_index()).expect("0 is a valid index"), + ChildNumber::from_hardened_idx(unit_index).expect("0 is a valid index"), ChildNumber::from_hardened_idx(index).expect("0 is a valid index"), - ]) + ])) } #[cfg(test)] @@ -598,7 +620,7 @@ mod tests { seed, 2, CurrencyUnit::Sat, - derivation_path_from_unit(CurrencyUnit::Sat, 0), + derivation_path_from_unit(CurrencyUnit::Sat, 0).unwrap(), ); assert_eq!(keyset.unit, CurrencyUnit::Sat); @@ -642,7 +664,7 @@ mod tests { xpriv, 2, CurrencyUnit::Sat, - derivation_path_from_unit(CurrencyUnit::Sat, 0), + derivation_path_from_unit(CurrencyUnit::Sat, 0).unwrap(), ); assert_eq!(keyset.unit, CurrencyUnit::Sat); @@ -722,6 +744,7 @@ mod tests { localstore, HashMap::new(), config.supported_units, + HashMap::new(), ) .await } @@ -777,7 +800,8 @@ mod tests { assert!(keysets.keysets.is_empty()); // generate the first keyset and set it to active - mint.rotate_keyset(CurrencyUnit::default(), 0, 1, 1).await?; + mint.rotate_keyset(CurrencyUnit::default(), 0, 1, 1, HashMap::new()) + .await?; let keysets = mint.keysets().await.unwrap(); assert!(keysets.keysets.len().eq(&1)); @@ -785,7 +809,8 @@ mod tests { let first_keyset_id = keysets.keysets[0].id; // set the first keyset to inactive and generate a new keyset - mint.rotate_keyset(CurrencyUnit::default(), 1, 1, 1).await?; + mint.rotate_keyset(CurrencyUnit::default(), 1, 1, 1, HashMap::new()) + .await?; let keysets = mint.keysets().await.unwrap(); diff --git a/crates/cdk/src/mint/swap.rs b/crates/cdk/src/mint/swap.rs index 16a72afe..88ccd0d1 100644 --- a/crates/cdk/src/mint/swap.rs +++ b/crates/cdk/src/mint/swap.rs @@ -166,6 +166,10 @@ impl Mint { .update_proofs_states(&input_ys, State::Spent) .await?; + for pub_key in input_ys { + self.pubsub_manager.proof_state((pub_key, State::Spent)); + } + self.localstore .add_blind_signatures( &swap_request diff --git a/crates/cdk/src/nuts/mod.rs b/crates/cdk/src/nuts/mod.rs index 07518bff..eb1f8170 100644 --- a/crates/cdk/src/nuts/mod.rs +++ b/crates/cdk/src/nuts/mod.rs @@ -18,6 +18,8 @@ pub mod nut12; pub mod nut13; pub mod nut14; pub mod nut15; +#[cfg(feature = "mint")] +pub mod nut17; pub mod nut18; pub use nut00::{ @@ -47,4 +49,6 @@ pub use nut11::{Conditions, P2PKWitness, SigFlag, SpendingConditions}; pub use nut12::{BlindSignatureDleq, ProofDleq}; pub use nut14::HTLCWitness; pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings}; +#[cfg(feature = "mint")] +pub use nut17::{NotificationPayload, PubSubManager}; pub use nut18::{PaymentRequest, PaymentRequestPayload, Transport}; diff --git a/crates/cdk/src/nuts/nut00/mod.rs b/crates/cdk/src/nuts/nut00/mod.rs index d6c3dffd..929b0c05 100644 --- a/crates/cdk/src/nuts/nut00/mod.rs +++ b/crates/cdk/src/nuts/nut00/mod.rs @@ -361,7 +361,7 @@ where /// Currency Unit #[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum CurrencyUnit { /// Sat @@ -373,17 +373,20 @@ pub enum CurrencyUnit { Usd, /// Euro Eur, + /// Custom currency unit + Custom(String), } #[cfg(feature = "mint")] impl CurrencyUnit { /// Derivation index mint will use for unit - pub fn derivation_index(&self) -> u32 { + pub fn derivation_index(&self) -> Option { match self { - Self::Sat => 0, - Self::Msat => 1, - Self::Usd => 2, - Self::Eur => 3, + Self::Sat => Some(0), + Self::Msat => Some(1), + Self::Usd => Some(2), + Self::Eur => Some(3), + _ => None, } } } @@ -391,12 +394,13 @@ impl CurrencyUnit { impl FromStr for CurrencyUnit { type Err = Error; fn from_str(value: &str) -> Result { - match value { - "sat" => Ok(Self::Sat), - "msat" => Ok(Self::Msat), - "usd" => Ok(Self::Usd), - "eur" => Ok(Self::Eur), - _ => Err(Error::UnsupportedUnit), + let value = &value.to_uppercase(); + match value.as_str() { + "SAT" => Ok(Self::Sat), + "MSAT" => Ok(Self::Msat), + "USD" => Ok(Self::Usd), + "EUR" => Ok(Self::Eur), + c => Ok(Self::Custom(c.to_string())), } } } @@ -404,15 +408,16 @@ impl FromStr for CurrencyUnit { impl fmt::Display for CurrencyUnit { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = match self { - CurrencyUnit::Sat => "sat", - CurrencyUnit::Msat => "msat", - CurrencyUnit::Usd => "usd", - CurrencyUnit::Eur => "eur", + CurrencyUnit::Sat => "SAT", + CurrencyUnit::Msat => "MSAT", + CurrencyUnit::Usd => "USD", + CurrencyUnit::Eur => "EUR", + CurrencyUnit::Custom(unit) => unit, }; if let Some(width) = f.width() { - write!(f, "{:width$}", s, width = width) + write!(f, "{:width$}", s.to_lowercase(), width = width) } else { - write!(f, "{}", s) + write!(f, "{}", s.to_lowercase()) } } } diff --git a/crates/cdk/src/nuts/nut00/token.rs b/crates/cdk/src/nuts/nut00/token.rs index 96e15302..dccd695b 100644 --- a/crates/cdk/src/nuts/nut00/token.rs +++ b/crates/cdk/src/nuts/nut00/token.rs @@ -92,8 +92,8 @@ impl Token { /// Unit pub fn unit(&self) -> Option { match self { - Self::TokenV3(token) => *token.unit(), - Self::TokenV4(token) => Some(token.unit()), + Self::TokenV3(token) => token.unit().clone(), + Self::TokenV4(token) => Some(token.unit().clone()), } } @@ -326,8 +326,8 @@ impl TokenV4 { /// Unit #[inline] - pub fn unit(&self) -> CurrencyUnit { - self.unit + pub fn unit(&self) -> &CurrencyUnit { + &self.unit } } @@ -525,7 +525,7 @@ mod tests { token.token[0].proofs[0].clone().keyset_id, Id::from_str("009a1f293253e41e").unwrap() ); - assert_eq!(token.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!(token.unit.clone().unwrap(), CurrencyUnit::Sat); let encoded = &token.to_string(); diff --git a/crates/cdk/src/nuts/nut02.rs b/crates/cdk/src/nuts/nut02.rs index 32e96190..0de24c98 100644 --- a/crates/cdk/src/nuts/nut02.rs +++ b/crates/cdk/src/nuts/nut02.rs @@ -18,7 +18,7 @@ use bitcoin::hashes::Hash; use bitcoin::key::Secp256k1; #[cfg(feature = "mint")] use bitcoin::secp256k1; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use serde_with::{serde_as, VecSkipError}; use thiserror::Error; @@ -86,10 +86,11 @@ impl fmt::Display for KeySetVersion { /// A keyset ID is an identifier for a specific keyset. It can be derived by /// anyone who knows the set of public keys of a mint. The keyset ID **CAN** -/// be stored in a Cashu token such that the token can be used to identify +/// be stored in a Cashu token such that the token can be used to identify /// which mint or keyset it was generated from. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(into = "String", try_from = "String")] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = String))] pub struct Id { version: KeySetVersion, id: [u8; Self::BYTELEN], @@ -130,17 +131,16 @@ impl fmt::Display for Id { } } -impl FromStr for Id { - type Err = Error; +impl TryFrom for Id { + type Error = Error; - fn from_str(s: &str) -> Result { - // Check if the string length is valid + fn try_from(s: String) -> Result { if s.len() != 16 { return Err(Error::Length); } Ok(Self { - version: KeySetVersion::Version00, + version: KeySetVersion::from_byte(&hex::decode(&s[..2])?[0])?, id: hex::decode(&s[2..])? .try_into() .map_err(|_| Error::Length)?, @@ -148,63 +148,29 @@ impl FromStr for Id { } } -impl Serialize for Id { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_str(&self.to_string()) +impl FromStr for Id { + type Err = Error; + + fn from_str(s: &str) -> Result { + Self::try_from(s.to_string()) } } -impl<'de> Deserialize<'de> for Id { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct IdVisitor; - - impl<'de> serde::de::Visitor<'de> for IdVisitor { - type Value = Id; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("Expecting a 14 char hex string") - } - - fn visit_str(self, v: &str) -> Result - where - E: serde::de::Error, - { - Id::from_str(v).map_err(|e| match e { - Error::Length => E::custom(format!( - "Invalid Length: Expected {}, got {}: - {}", - Id::STRLEN, - v.len(), - v - )), - _ => E::custom(e), - }) - } - } - - deserializer.deserialize_str(IdVisitor) +impl From for String { + fn from(value: Id) -> Self { + value.to_string() } } impl From<&Keys> for Id { + /// As per NUT-02: + /// 1. sort public keys by their amount in ascending order + /// 2. concatenate all public keys to one string + /// 3. HASH_SHA256 the concatenated public keys + /// 4. take the first 14 characters of the hex-encoded hash + /// 5. prefix it with a keyset ID version byte fn from(map: &Keys) -> Self { - // REVIEW: Is it 16 or 14 bytes - /* NUT-02 - 1 - sort public keys by their amount in ascending order - 2 - concatenate all public keys to one string - 3 - HASH_SHA256 the concatenated public keys - 4 - take the first 14 characters of the hex-encoded hash - 5 - prefix it with a keyset ID version byte - */ - let mut keys: Vec<(&AmountStr, &super::PublicKey)> = map.iter().collect(); - keys.sort_by_key(|(amt, _v)| *amt); let pubkeys_concat: Vec = keys @@ -400,12 +366,14 @@ impl From<&MintKeys> for Id { #[cfg(test)] mod test { - use std::str::FromStr; + use rand::RngCore; + use super::{KeySetInfo, Keys, KeysetResponse}; - use crate::nuts::nut02::Id; + use crate::nuts::nut02::{Error, Id}; use crate::nuts::KeysResponse; + use crate::util::hex; const SHORT_KEYSET_ID: &str = "00456a94ab4e1c46"; const SHORT_KEYSET: &str = r#" @@ -547,4 +515,36 @@ mod test { assert_eq!(keys_response.keysets.len(), 2); } + + fn generate_random_id() -> Id { + let mut rand_bytes = vec![0u8; 8]; + rand::thread_rng().fill_bytes(&mut rand_bytes[1..]); + Id::from_bytes(&rand_bytes) + .unwrap_or_else(|e| panic!("Failed to create Id from {}: {e}", hex::encode(rand_bytes))) + } + + #[test] + fn test_id_serialization() { + let id = generate_random_id(); + let id_str = id.to_string(); + + assert!(id_str.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(16, id_str.len()); + assert_eq!(id_str.to_lowercase(), id_str); + } + + #[test] + fn test_id_deserialization() { + let id_from_short_str = Id::from_str("00123"); + assert!(matches!(id_from_short_str, Err(Error::Length))); + + let id_from_non_hex_str = Id::from_str(&SHORT_KEYSET_ID.replace('a', "x")); + assert!(matches!(id_from_non_hex_str, Err(Error::HexError(_)))); + + let id_invalid_version = Id::from_str(&SHORT_KEYSET_ID.replace("00", "99")); + assert!(matches!(id_invalid_version, Err(Error::UnknownVersion))); + + let id_from_uppercase = Id::from_str(&SHORT_KEYSET_ID.to_uppercase()); + assert!(id_from_uppercase.is_ok()); + } } diff --git a/crates/cdk/src/nuts/nut03.rs b/crates/cdk/src/nuts/nut03.rs index 535c89a9..cee5f063 100644 --- a/crates/cdk/src/nuts/nut03.rs +++ b/crates/cdk/src/nuts/nut03.rs @@ -32,11 +32,11 @@ pub struct PreSwap { pub fee: Amount, } -/// Split Request [NUT-06] +/// Swap Request [NUT-03] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct SwapRequest { - /// Proofs that are to be spent in `Split` + /// Proofs that are to be spent in a `Swap` #[cfg_attr(feature = "swagger", schema(value_type = Vec))] pub inputs: Proofs, /// Blinded Messages for Mint to sign diff --git a/crates/cdk/src/nuts/nut04.rs b/crates/cdk/src/nuts/nut04.rs index 3067628f..40a6f8d4 100644 --- a/crates/cdk/src/nuts/nut04.rs +++ b/crates/cdk/src/nuts/nut04.rs @@ -5,8 +5,7 @@ use std::fmt; use std::str::FromStr; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; +use serde::{Deserialize, Serialize}; use thiserror::Error; use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod}; @@ -80,96 +79,25 @@ impl FromStr for QuoteState { } /// Mint quote response [NUT-04] -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct MintQuoteBolt11Response { /// Quote Id pub quote: String, /// Payment request to fulfil pub request: String, - // TODO: To be deprecated - /// Whether the the request haas be paid - /// Deprecated - pub paid: Option, /// Quote State pub state: MintQuoteState, /// Unix timestamp until the quote is valid pub expiry: Option, } -// A custom deserializer is needed until all mints -// update some will return without the required state. -impl<'de> Deserialize<'de> for MintQuoteBolt11Response { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = Value::deserialize(deserializer)?; - - let quote: String = serde_json::from_value( - value - .get("quote") - .ok_or(serde::de::Error::missing_field("quote"))? - .clone(), - ) - .map_err(|_| serde::de::Error::custom("Invalid quote id string"))?; - - let request: String = serde_json::from_value( - value - .get("request") - .ok_or(serde::de::Error::missing_field("request"))? - .clone(), - ) - .map_err(|_| serde::de::Error::custom("Invalid request string"))?; - - let paid: Option = value.get("paid").and_then(|p| p.as_bool()); - - let state: Option = value - .get("state") - .and_then(|s| serde_json::from_value(s.clone()).ok()); - - let (state, paid) = match (state, paid) { - (None, None) => return Err(serde::de::Error::custom("State or paid must be defined")), - (Some(state), _) => { - let state: QuoteState = QuoteState::from_str(&state) - .map_err(|_| serde::de::Error::custom("Unknown state"))?; - let paid = state == QuoteState::Paid; - - (state, paid) - } - (None, Some(paid)) => { - let state = if paid { - QuoteState::Paid - } else { - QuoteState::Unpaid - }; - (state, paid) - } - }; - - let expiry = value - .get("expiry") - .ok_or(serde::de::Error::missing_field("expiry"))? - .as_u64(); - - Ok(Self { - quote, - request, - paid: Some(paid), - state, - expiry, - }) - } -} - #[cfg(feature = "mint")] impl From for MintQuoteBolt11Response { fn from(mint_quote: crate::mint::MintQuote) -> MintQuoteBolt11Response { - let paid = mint_quote.state == QuoteState::Paid; MintQuoteBolt11Response { quote: mint_quote.id, request: mint_quote.request, - paid: Some(paid), state: mint_quote.state, expiry: Some(mint_quote.expiry), } @@ -209,7 +137,7 @@ pub struct MintBolt11Response { } /// Mint Method Settings -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct MintMethodSettings { /// Payment Method e.g. bolt11 @@ -251,7 +179,7 @@ impl Settings { ) -> Option { for method_settings in self.methods.iter() { if method_settings.method.eq(method) && method_settings.unit.eq(unit) { - return Some(*method_settings); + return Some(method_settings.clone()); } } diff --git a/crates/cdk/src/nuts/nut05.rs b/crates/cdk/src/nuts/nut05.rs index e7ac3153..df9064ef 100644 --- a/crates/cdk/src/nuts/nut05.rs +++ b/crates/cdk/src/nuts/nut05.rs @@ -12,7 +12,7 @@ use thiserror::Error; use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, Proofs}; use super::nut15::Mpp; #[cfg(feature = "mint")] -use crate::mint; +use crate::mint::{self, MeltQuote}; use crate::nuts::MeltQuoteState; use crate::{Amount, Bolt11Invoice}; @@ -111,6 +111,22 @@ pub struct MeltQuoteBolt11Response { pub change: Option>, } +#[cfg(feature = "mint")] +impl From<&MeltQuote> for MeltQuoteBolt11Response { + fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response { + MeltQuoteBolt11Response { + quote: melt_quote.id.clone(), + payment_preimage: None, + change: None, + state: melt_quote.state, + paid: Some(melt_quote.state == MeltQuoteState::Paid), + expiry: melt_quote.expiry, + amount: melt_quote.amount, + fee_reserve: melt_quote.fee_reserve, + } + } +} + // A custom deserializer is needed until all mints // update some will return without the required state. impl<'de> Deserialize<'de> for MeltQuoteBolt11Response { @@ -235,7 +251,7 @@ impl MeltBolt11Request { } /// Melt Method Settings -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct MeltMethodSettings { /// Payment Method e.g. bolt11 @@ -264,7 +280,7 @@ impl Settings { ) -> Option { for method_settings in self.methods.iter() { if method_settings.method.eq(method) && method_settings.unit.eq(unit) { - return Some(*method_settings); + return Some(method_settings.clone()); } } diff --git a/crates/cdk/src/nuts/nut06.rs b/crates/cdk/src/nuts/nut06.rs index 358cc4ff..17ba18b6 100644 --- a/crates/cdk/src/nuts/nut06.rs +++ b/crates/cdk/src/nuts/nut06.rs @@ -78,6 +78,9 @@ pub struct MintInfo { /// Mint's icon URL #[serde(skip_serializing_if = "Option::is_none")] pub icon_url: Option, + /// Mint's endpoint URLs + #[serde(skip_serializing_if = "Option::is_none")] + pub urls: Option>, /// message of the day that the wallet must display to the user #[serde(skip_serializing_if = "Option::is_none")] pub motd: Option, @@ -232,6 +235,11 @@ pub struct Nuts { #[serde(default)] #[serde(rename = "15")] pub nut15: nut15::Settings, + /// NUT17 Settings + #[serde(default)] + #[serde(rename = "17")] + #[cfg(feature = "mint")] + pub nut17: super::nut17::SupportedSettings, } impl Nuts { diff --git a/crates/cdk/src/nuts/nut07.rs b/crates/cdk/src/nuts/nut07.rs index b56830ea..11dbf6ec 100644 --- a/crates/cdk/src/nuts/nut07.rs +++ b/crates/cdk/src/nuts/nut07.rs @@ -88,6 +88,16 @@ pub struct ProofState { pub witness: Option, } +impl From<(PublicKey, State)> for ProofState { + fn from(value: (PublicKey, State)) -> Self { + Self { + y: value.0, + state: value.1, + witness: None, + } + } +} + /// Check Spendable Response [NUT-07] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] diff --git a/crates/cdk/src/nuts/nut09.rs b/crates/cdk/src/nuts/nut09.rs index abcd8f85..5f04045f 100644 --- a/crates/cdk/src/nuts/nut09.rs +++ b/crates/cdk/src/nuts/nut09.rs @@ -22,6 +22,9 @@ pub struct RestoreResponse { pub outputs: Vec, /// Signatures pub signatures: Vec, + /// Promises + // Temp compatibility with cashu-ts + pub promises: Option>, } mod test { diff --git a/crates/cdk/src/nuts/nut17/mod.rs b/crates/cdk/src/nuts/nut17/mod.rs new file mode 100644 index 00000000..7d2a3c4a --- /dev/null +++ b/crates/cdk/src/nuts/nut17/mod.rs @@ -0,0 +1,350 @@ +//! Specific Subscription for the cdk crate + +use std::{ops::Deref, sync::Arc}; + +use serde::{Deserialize, Serialize}; + +mod on_subscription; + +use crate::cdk_database::{self, MintDatabase}; +use crate::nuts::{BlindSignature, CurrencyUnit, PaymentMethod}; +use crate::{ + nuts::{ + MeltQuoteBolt11Response, MeltQuoteState, MintQuoteBolt11Response, MintQuoteState, + ProofState, + }, + pub_sub::{self, Index, Indexable, SubscriptionGlobalId}, +}; + +pub use crate::pub_sub::SubId; +pub use on_subscription::OnSubscription; + +/// Subscription Parameter according to the standard +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Params { + /// Kind + pub kind: Kind, + /// Filters + pub filters: Vec, + /// Subscription Id + #[serde(rename = "subId")] + pub id: SubId, +} + +/// Check state Settings +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SupportedSettings { + supported: Vec, +} + +impl Default for SupportedSettings { + fn default() -> Self { + SupportedSettings { + supported: vec![SupportedMethods::default()], + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +struct SupportedMethods { + method: PaymentMethod, + unit: CurrencyUnit, + commands: Vec, +} + +impl Default for SupportedMethods { + fn default() -> Self { + SupportedMethods { + method: PaymentMethod::Bolt11, + unit: CurrencyUnit::Sat, + commands: vec![ + "bolt11_mint_quote".to_owned(), + "bolt11_melt_quote".to_owned(), + "proof_state".to_owned(), + ], + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +/// Subscription response +pub enum NotificationPayload { + /// Proof State + ProofState(ProofState), + /// Melt Quote Bolt11 Response + MeltQuoteBolt11Response(MeltQuoteBolt11Response), + /// Mint Quote Bolt11 Response + MintQuoteBolt11Response(MintQuoteBolt11Response), +} + +impl From for NotificationPayload { + fn from(proof_state: ProofState) -> NotificationPayload { + NotificationPayload::ProofState(proof_state) + } +} + +impl From for NotificationPayload { + fn from(melt_quote: MeltQuoteBolt11Response) -> NotificationPayload { + NotificationPayload::MeltQuoteBolt11Response(melt_quote) + } +} + +impl From for NotificationPayload { + fn from(mint_quote: MintQuoteBolt11Response) -> NotificationPayload { + NotificationPayload::MintQuoteBolt11Response(mint_quote) + } +} + +impl Indexable for NotificationPayload { + type Type = (String, Kind); + + fn to_indexes(&self) -> Vec> { + match self { + NotificationPayload::ProofState(proof_state) => { + vec![Index::from((proof_state.y.to_hex(), Kind::ProofState))] + } + NotificationPayload::MeltQuoteBolt11Response(melt_quote) => { + vec![Index::from(( + melt_quote.quote.clone(), + Kind::Bolt11MeltQuote, + ))] + } + NotificationPayload::MintQuoteBolt11Response(mint_quote) => { + vec![Index::from(( + mint_quote.quote.clone(), + Kind::Bolt11MintQuote, + ))] + } + } + } +} + +#[derive(Debug, Clone, Copy, Eq, Ord, PartialOrd, PartialEq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] + +/// Kind +pub enum Kind { + /// Bolt 11 Melt Quote + Bolt11MeltQuote, + /// Bolt 11 Mint Quote + Bolt11MintQuote, + /// Proof State + ProofState, +} + +impl AsRef for Params { + fn as_ref(&self) -> &SubId { + &self.id + } +} + +impl From for Vec> { + fn from(val: Params) -> Self { + let sub_id: SubscriptionGlobalId = Default::default(); + val.filters + .iter() + .map(|filter| Index::from(((filter.clone(), val.kind), val.id.clone(), sub_id))) + .collect() + } +} + +/// Manager +/// Publish–subscribe manager +/// +/// Nut-17 implementation is system-wide and not only through the WebSocket, so +/// it is possible for another part of the system to subscribe to events. +pub struct PubSubManager(pub_sub::Manager); + +#[allow(clippy::default_constructed_unit_structs)] +impl Default for PubSubManager { + fn default() -> Self { + PubSubManager(OnSubscription::default().into()) + } +} + +impl From + Send + Sync>> for PubSubManager { + fn from(val: Arc + Send + Sync>) -> Self { + PubSubManager(OnSubscription(Some(val)).into()) + } +} + +impl Deref for PubSubManager { + type Target = pub_sub::Manager; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PubSubManager { + /// Helper function to emit a ProofState status + pub fn proof_state>(&self, event: E) { + self.broadcast(event.into().into()); + } + + /// Helper function to emit a MintQuoteBolt11Response status + pub fn mint_quote_bolt11_status>( + &self, + quote: E, + new_state: MintQuoteState, + ) { + let mut event = quote.into(); + event.state = new_state; + + self.broadcast(event.into()); + } + + /// Helper function to emit a MeltQuoteBolt11Response status + pub fn melt_quote_status>( + &self, + quote: E, + payment_preimage: Option, + change: Option>, + new_state: MeltQuoteState, + ) { + let mut quote = quote.into(); + quote.state = new_state; + quote.paid = Some(new_state == MeltQuoteState::Paid); + quote.payment_preimage = payment_preimage; + quote.change = change; + self.broadcast(quote.into()); + } +} + +#[cfg(test)] +mod test { + use crate::nuts::{PublicKey, State}; + + use super::*; + use std::time::Duration; + use tokio::time::sleep; + + #[tokio::test] + async fn active_and_drop() { + let manager = PubSubManager::default(); + let params = Params { + kind: Kind::ProofState, + filters: vec!["x".to_string()], + id: "uno".into(), + }; + + // Although the same param is used, two subscriptions are created, that + // is because each index is unique, thanks to `Unique`, it is the + // responsibility of the implementor to make sure that SubId are unique + // either globally or per client + let subscriptions = vec![ + manager.subscribe(params.clone()).await, + manager.subscribe(params).await, + ]; + assert_eq!(2, manager.active_subscriptions()); + drop(subscriptions); + + sleep(Duration::from_millis(10)).await; + + assert_eq!(0, manager.active_subscriptions()); + } + + #[tokio::test] + async fn broadcast() { + let manager = PubSubManager::default(); + let mut subscriptions = [ + manager + .subscribe(Params { + kind: Kind::ProofState, + filters: vec![ + "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" + .to_string(), + ], + id: "uno".into(), + }) + .await, + manager + .subscribe(Params { + kind: Kind::ProofState, + filters: vec![ + "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" + .to_string(), + ], + id: "dos".into(), + }) + .await, + ]; + + let event = ProofState { + y: PublicKey::from_hex( + "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104", + ) + .expect("valid pk"), + state: State::Pending, + witness: None, + }; + + manager.broadcast(event.into()); + + sleep(Duration::from_millis(10)).await; + + let (sub1, _) = subscriptions[0].try_recv().expect("valid message"); + assert_eq!("uno", *sub1); + + let (sub1, _) = subscriptions[1].try_recv().expect("valid message"); + assert_eq!("dos", *sub1); + + assert!(subscriptions[0].try_recv().is_err()); + assert!(subscriptions[1].try_recv().is_err()); + } + + #[test] + fn parsing_request() { + let json = r#"{"kind":"proof_state","filters":["x"],"subId":"uno"}"#; + let params: Params = serde_json::from_str(json).expect("valid json"); + assert_eq!(params.kind, Kind::ProofState); + assert_eq!(params.filters, vec!["x"]); + assert_eq!(*params.id, "uno"); + } + + #[tokio::test] + async fn json_test() { + let manager = PubSubManager::default(); + let mut subscription = manager + .subscribe::( + serde_json::from_str(r#"{"kind":"proof_state","filters":["02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"],"subId":"uno"}"#) + .expect("valid json"), + ) + .await; + + manager.broadcast( + ProofState { + y: PublicKey::from_hex( + "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104", + ) + .expect("valid pk"), + state: State::Pending, + witness: None, + } + .into(), + ); + + // no one is listening for this event + manager.broadcast( + ProofState { + y: PublicKey::from_hex( + "020000000000000000000000000000000000000000000000000000000000000001", + ) + .expect("valid pk"), + state: State::Pending, + witness: None, + } + .into(), + ); + + sleep(Duration::from_millis(10)).await; + let (sub1, msg) = subscription.try_recv().expect("valid message"); + assert_eq!("uno", *sub1); + assert_eq!( + r#"{"Y":"02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104","state":"PENDING","witness":null}"#, + serde_json::to_string(&msg).expect("valid json") + ); + assert!(subscription.try_recv().is_err()); + } +} diff --git a/crates/cdk/src/nuts/nut17/on_subscription.rs b/crates/cdk/src/nuts/nut17/on_subscription.rs new file mode 100644 index 00000000..9aaf8c18 --- /dev/null +++ b/crates/cdk/src/nuts/nut17/on_subscription.rs @@ -0,0 +1,111 @@ +//! On Subscription +//! +//! This module contains the code that is triggered when a new subscription is created. +use std::{collections::HashMap, sync::Arc}; + +use super::{Kind, NotificationPayload}; +use crate::{ + cdk_database::{self, MintDatabase}, + nuts::{MeltQuoteBolt11Response, MintQuoteBolt11Response, ProofState, PublicKey}, + pub_sub::OnNewSubscription, +}; + +#[derive(Default)] +/// Subscription Init +/// +/// This struct triggers code when a new subscription is created. +/// +/// It is used to send the initial state of the subscription to the client. +pub struct OnSubscription( + pub(crate) Option + Send + Sync>>, +); + +#[async_trait::async_trait] +impl OnNewSubscription for OnSubscription { + type Event = NotificationPayload; + type Index = (String, Kind); + + async fn on_new_subscription( + &self, + request: &[&Self::Index], + ) -> Result, String> { + let datastore = if let Some(localstore) = self.0.as_ref() { + localstore + } else { + return Ok(vec![]); + }; + + let mut to_return = vec![]; + + for (kind, values) in request.iter().fold( + HashMap::new(), + |mut acc: HashMap<&Kind, Vec<&String>>, (data, kind)| { + acc.entry(kind).or_default().push(data); + acc + }, + ) { + match kind { + Kind::Bolt11MeltQuote => { + let queries = values + .iter() + .map(|id| datastore.get_melt_quote(id)) + .collect::>(); + + to_return.extend( + futures::future::try_join_all(queries) + .await + .map(|quotes| { + quotes + .into_iter() + .filter_map(|quote| quote.map(|x| x.into())) + .map(|x: MeltQuoteBolt11Response| x.into()) + .collect::>() + }) + .map_err(|e| e.to_string())?, + ); + } + Kind::Bolt11MintQuote => { + let queries = values + .iter() + .map(|id| datastore.get_mint_quote(id)) + .collect::>(); + + to_return.extend( + futures::future::try_join_all(queries) + .await + .map(|quotes| { + quotes + .into_iter() + .filter_map(|quote| quote.map(|x| x.into())) + .map(|x: MintQuoteBolt11Response| x.into()) + .collect::>() + }) + .map_err(|e| e.to_string())?, + ); + } + Kind::ProofState => { + let public_keys = values + .iter() + .map(PublicKey::from_hex) + .collect::, _>>() + .map_err(|e| e.to_string())?; + + to_return.extend( + datastore + .get_proofs_states(&public_keys) + .await + .map_err(|e| e.to_string())? + .into_iter() + .enumerate() + .filter_map(|(idx, state)| { + state.map(|state| (public_keys[idx], state).into()) + }) + .map(|state: ProofState| state.into()), + ); + } + } + } + + Ok(to_return) + } +} diff --git a/crates/cdk/src/nuts/nut18.rs b/crates/cdk/src/nuts/nut18.rs index d165b148..cb19735e 100644 --- a/crates/cdk/src/nuts/nut18.rs +++ b/crates/cdk/src/nuts/nut18.rs @@ -154,7 +154,7 @@ mod tests { assert_eq!(&req.payment_id.unwrap(), "b7a90176"); assert_eq!(req.amount.unwrap(), 10.into()); - assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!(req.unit.clone().unwrap(), CurrencyUnit::Sat); assert_eq!( req.mints.unwrap(), vec![MintUrl::from_str("https://nofees.testnut.cashu.space")?] @@ -190,7 +190,7 @@ mod tests { assert_eq!(&req.payment_id.unwrap(), "b7a90176"); assert_eq!(req.amount.unwrap(), 10.into()); - assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!(req.unit.clone().unwrap(), CurrencyUnit::Sat); assert_eq!( req.mints.unwrap(), vec![MintUrl::from_str("https://nofees.testnut.cashu.space")?] diff --git a/crates/cdk/src/pub_sub/index.rs b/crates/cdk/src/pub_sub/index.rs new file mode 100644 index 00000000..b19f4a38 --- /dev/null +++ b/crates/cdk/src/pub_sub/index.rs @@ -0,0 +1,160 @@ +use super::SubId; +use std::{ + fmt::Debug, + ops::Deref, + sync::atomic::{AtomicUsize, Ordering}, +}; + +/// Indexable trait +pub trait Indexable { + /// The type of the index, it is unknown and it is up to the Manager's + /// generic type + type Type: PartialOrd + Ord + Send + Sync + Debug; + + /// To indexes + fn to_indexes(&self) -> Vec>; +} + +#[derive(Debug, Ord, PartialOrd, PartialEq, Eq, Clone)] +/// Index +/// +/// The Index is a sorted structure that is used to quickly find matches +/// +/// The counter is used to make sure each Index is unique, even if the prefix +/// are the same, and also to make sure that earlier indexes matches first +pub struct Index +where + T: PartialOrd + Ord + Send + Sync + Debug, +{ + prefix: T, + counter: SubscriptionGlobalId, + id: super::SubId, +} + +impl From<&Index> for super::SubId +where + T: PartialOrd + Ord + Send + Sync + Debug, +{ + fn from(val: &Index) -> Self { + val.id.clone() + } +} + +impl Deref for Index +where + T: PartialOrd + Ord + Send + Sync + Debug, +{ + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.prefix + } +} + +impl Index +where + T: PartialOrd + Ord + Send + Sync + Debug, +{ + /// Compare the + pub fn cmp_prefix(&self, other: &Index) -> std::cmp::Ordering { + self.prefix.cmp(&other.prefix) + } + + /// Returns a globally unique id for the Index + pub fn unique_id(&self) -> usize { + self.counter.0 + } +} + +impl From<(T, SubId, SubscriptionGlobalId)> for Index +where + T: PartialOrd + Ord + Send + Sync + Debug, +{ + fn from((prefix, id, counter): (T, SubId, SubscriptionGlobalId)) -> Self { + Self { + prefix, + id, + counter, + } + } +} + +impl From<(T, SubId)> for Index +where + T: PartialOrd + Ord + Send + Sync + Debug, +{ + fn from((prefix, id): (T, SubId)) -> Self { + Self { + prefix, + id, + counter: Default::default(), + } + } +} + +impl From for Index +where + T: PartialOrd + Ord + Send + Sync + Debug, +{ + fn from(prefix: T) -> Self { + Self { + prefix, + id: Default::default(), + counter: SubscriptionGlobalId(0), + } + } +} + +static COUNTER: AtomicUsize = AtomicUsize::new(0); + +/// Dummy type +/// +/// This is only use so each Index is unique, with the same prefix. +/// +/// The prefix is used to leverage the BTree to find things quickly, but each +/// entry/key must be unique, so we use this dummy type to make sure each Index +/// is unique. +/// +/// Unique is also used to make sure that the indexes are sorted by creation order +#[derive(Debug, Ord, PartialOrd, PartialEq, Eq, Clone, Copy)] +pub struct SubscriptionGlobalId(usize); + +impl Default for SubscriptionGlobalId { + fn default() -> Self { + Self(COUNTER.fetch_add(1, Ordering::Relaxed)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_index_from_tuple() { + let sub_id = SubId::from("test_sub_id"); + let prefix = "test_prefix"; + let index: Index<&str> = Index::from((prefix, sub_id.clone())); + assert_eq!(index.prefix, "test_prefix"); + assert_eq!(index.id, sub_id); + } + + #[test] + fn test_index_cmp_prefix() { + let sub_id = SubId::from("test_sub_id"); + let index1: Index<&str> = Index::from(("a", sub_id.clone())); + let index2: Index<&str> = Index::from(("b", sub_id.clone())); + assert_eq!(index1.cmp_prefix(&index2), std::cmp::Ordering::Less); + } + + #[test] + fn test_sub_id_from_str() { + let sub_id = SubId::from("test_sub_id"); + assert_eq!(sub_id.0, "test_sub_id"); + } + + #[test] + fn test_sub_id_deref() { + let sub_id = SubId::from("test_sub_id"); + assert_eq!(&*sub_id, "test_sub_id"); + } +} diff --git a/crates/cdk/src/pub_sub/mod.rs b/crates/cdk/src/pub_sub/mod.rs new file mode 100644 index 00000000..c511c803 --- /dev/null +++ b/crates/cdk/src/pub_sub/mod.rs @@ -0,0 +1,370 @@ +//! Publish–subscribe pattern. +//! +//! This is a generic implementation for +//! [NUT-17(https://github.com/cashubtc/nuts/blob/main/17.md) with a type +//! agnostic Publish-subscribe manager. +//! +//! The manager has a method for subscribers to subscribe to events with a +//! generic type that must be converted to a vector of indexes. +//! +//! Events are also generic that should implement the `Indexable` trait. +use serde::{Deserialize, Serialize}; +use std::{ + cmp::Ordering, + collections::{BTreeMap, HashSet}, + fmt::Debug, + ops::{Deref, DerefMut}, + str::FromStr, + sync::{ + atomic::{self, AtomicUsize}, + Arc, + }, +}; +use tokio::{ + sync::{mpsc, RwLock}, + task::JoinHandle, +}; + +mod index; + +pub use index::{Index, Indexable, SubscriptionGlobalId}; + +type IndexTree = Arc, mpsc::Sender<(SubId, T)>>>>; + +/// Default size of the remove channel +pub const DEFAULT_REMOVE_SIZE: usize = 10_000; + +/// Default channel size for subscription buffering +pub const DEFAULT_CHANNEL_SIZE: usize = 10; + +#[async_trait::async_trait] +/// On New Subscription trait +/// +/// This trait is optional and it is used to notify the application when a new +/// subscription is created. This is useful when the application needs to send +/// the initial state to the subscriber upon subscription +pub trait OnNewSubscription { + /// Index type + type Index; + /// Subscription event type + type Event; + + /// Called when a new subscription is created + async fn on_new_subscription( + &self, + request: &[&Self::Index], + ) -> Result, String>; +} + +/// Subscription manager +/// +/// This object keep track of all subscription listener and it is also +/// responsible for broadcasting events to all listeners +/// +/// The content of the notification is not relevant to this scope and it is up +/// to the application, therefore the generic T is used instead of a specific +/// type +pub struct Manager +where + T: Indexable + Clone + Send + Sync + 'static, + I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, + F: OnNewSubscription + 'static, +{ + indexes: IndexTree, + on_new_subscription: Option, + unsubscription_sender: mpsc::Sender<(SubId, Vec>)>, + active_subscriptions: Arc, + background_subscription_remover: Option>, +} + +impl Default for Manager +where + T: Indexable + Clone + Send + Sync + 'static, + I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, + F: OnNewSubscription + 'static, +{ + fn default() -> Self { + let (sender, receiver) = mpsc::channel(DEFAULT_REMOVE_SIZE); + let active_subscriptions: Arc = Default::default(); + let storage: IndexTree = Arc::new(Default::default()); + + Self { + background_subscription_remover: Some(tokio::spawn(Self::remove_subscription( + receiver, + storage.clone(), + active_subscriptions.clone(), + ))), + on_new_subscription: None, + unsubscription_sender: sender, + active_subscriptions, + indexes: storage, + } + } +} + +impl From for Manager +where + T: Indexable + Clone + Send + Sync + 'static, + I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, + F: OnNewSubscription + 'static, +{ + fn from(value: F) -> Self { + let mut manager: Self = Default::default(); + manager.on_new_subscription = Some(value); + manager + } +} + +impl Manager +where + T: Indexable + Clone + Send + Sync + 'static, + I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, + F: OnNewSubscription + 'static, +{ + #[inline] + /// Broadcast an event to all listeners + /// + /// This function takes an Arc to the storage struct, the event_id, the kind + /// and the vent to broadcast + async fn broadcast_impl(storage: &IndexTree, event: T) { + let index_storage = storage.read().await; + let mut sent = HashSet::new(); + for index in event.to_indexes() { + for (key, sender) in index_storage.range(index.clone()..) { + if index.cmp_prefix(key) != Ordering::Equal { + break; + } + let sub_id = key.unique_id(); + if sent.contains(&sub_id) { + continue; + } + sent.insert(sub_id); + let _ = sender.try_send((key.into(), event.clone())); + } + } + } + + /// Broadcasts an event to all listeners + /// + /// This public method will not block the caller, it will spawn a new task + /// instead + pub fn broadcast(&self, event: T) { + let storage = self.indexes.clone(); + tokio::spawn(async move { + Self::broadcast_impl(&storage, event).await; + }); + } + + /// Broadcasts an event to all listeners + /// + /// This method is async and will await for the broadcast to be completed + pub async fn broadcast_async(&self, event: T) { + Self::broadcast_impl(&self.indexes, event).await; + } + + /// Subscribe to a specific event + pub async fn subscribe + Into>>>( + &self, + params: P, + ) -> ActiveSubscription { + let (sender, receiver) = mpsc::channel(10); + let sub_id: SubId = params.as_ref().clone(); + + let indexes: Vec> = params.into(); + + if let Some(on_new_subscription) = self.on_new_subscription.as_ref() { + match on_new_subscription + .on_new_subscription(&indexes.iter().map(|x| x.deref()).collect::>()) + .await + { + Ok(events) => { + for event in events { + let _ = sender.try_send((sub_id.clone(), event)); + } + } + Err(err) => { + tracing::info!( + "Failed to get initial state for subscription: {:?}, {}", + sub_id, + err + ); + } + } + } + + let mut index_storage = self.indexes.write().await; + for index in indexes.clone() { + index_storage.insert(index, sender.clone()); + } + drop(index_storage); + + self.active_subscriptions + .fetch_add(1, atomic::Ordering::Relaxed); + + ActiveSubscription { + sub_id, + receiver, + indexes, + drop: self.unsubscription_sender.clone(), + } + } + + /// Return number of active subscriptions + pub fn active_subscriptions(&self) -> usize { + self.active_subscriptions.load(atomic::Ordering::SeqCst) + } + + /// Task to remove dropped subscriptions from the storage struct + /// + /// This task will run in the background (and will be dropped when the [`Manager`] + /// is) and will remove subscriptions from the storage struct it is dropped. + async fn remove_subscription( + mut receiver: mpsc::Receiver<(SubId, Vec>)>, + storage: IndexTree, + active_subscriptions: Arc, + ) { + while let Some((sub_id, indexes)) = receiver.recv().await { + tracing::info!("Removing subscription: {}", *sub_id); + + active_subscriptions.fetch_sub(1, atomic::Ordering::AcqRel); + + let mut index_storage = storage.write().await; + for key in indexes { + index_storage.remove(&key); + } + drop(index_storage); + } + } +} + +/// Manager goes out of scope, stop all background tasks +impl Drop for Manager +where + T: Indexable + Clone + Send + Sync + 'static, + I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, + F: OnNewSubscription + 'static, +{ + fn drop(&mut self) { + if let Some(handler) = self.background_subscription_remover.take() { + handler.abort(); + } + } +} + +/// Active Subscription +/// +/// This struct is a wrapper around the mpsc::Receiver and it also used +/// to keep track of the subscription itself. When this struct goes out of +/// scope, it will notify the Manager about it, so it can be removed from the +/// list of active listeners +pub struct ActiveSubscription +where + T: Send + Sync, + I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, +{ + /// The subscription ID + pub sub_id: SubId, + indexes: Vec>, + receiver: mpsc::Receiver<(SubId, T)>, + drop: mpsc::Sender<(SubId, Vec>)>, +} + +impl Deref for ActiveSubscription +where + T: Send + Sync, + I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, +{ + type Target = mpsc::Receiver<(SubId, T)>; + + fn deref(&self) -> &Self::Target { + &self.receiver + } +} + +impl DerefMut for ActiveSubscription +where + T: Indexable + Clone + Send + Sync + 'static, + I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, +{ + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.receiver + } +} + +/// The ActiveSubscription is Drop out of scope, notify the Manager about it, so +/// it can be removed from the list of active listeners +/// +/// Having this in place, we can avoid memory leaks and also makes it super +/// simple to implement the Unsubscribe method +impl Drop for ActiveSubscription +where + T: Send + Sync, + I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, +{ + fn drop(&mut self) { + let _ = self + .drop + .try_send((self.sub_id.clone(), self.indexes.drain(..).collect())); + } +} + +/// Subscription Id wrapper +/// +/// This is the place to add some sane default (like a max length) to the +/// subscription ID +#[derive(Debug, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub struct SubId(String); + +impl From<&str> for SubId { + fn from(s: &str) -> Self { + Self(s.to_string()) + } +} + +impl From for SubId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl FromStr for SubId { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(Self(s.to_string())) + } +} + +impl Deref for SubId { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[cfg(test)] +mod test { + use super::*; + use tokio::sync::mpsc; + + #[test] + fn test_active_subscription_drop() { + let (tx, rx) = mpsc::channel::<(SubId, ())>(10); + let sub_id = SubId::from("test_sub_id"); + let indexes: Vec> = vec![Index::from(("test".to_string(), sub_id.clone()))]; + let (drop_tx, mut drop_rx) = mpsc::channel(10); + + { + let _active_subscription = ActiveSubscription { + sub_id: sub_id.clone(), + indexes, + receiver: rx, + drop: drop_tx, + }; + // When it goes out of scope, it should notify + } + assert_eq!(drop_rx.try_recv().unwrap().0, sub_id); // it should have notified + assert!(tx.try_send(("foo".into(), ())).is_err()); // subscriber is dropped + } +} diff --git a/crates/cdk/src/types.rs b/crates/cdk/src/types.rs index 213a8e7e..e3d91eca 100644 --- a/crates/cdk/src/types.rs +++ b/crates/cdk/src/types.rs @@ -141,7 +141,7 @@ impl ProofInfo { /// Key used in hashmap of ln backends to identify what unit and payment method /// it is for -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct LnKey { /// Unit of Payment backend pub unit: CurrencyUnit, diff --git a/crates/cdk/src/wallet/balance.rs b/crates/cdk/src/wallet/balance.rs index 8fe277b9..46c814ac 100644 --- a/crates/cdk/src/wallet/balance.rs +++ b/crates/cdk/src/wallet/balance.rs @@ -2,44 +2,24 @@ use std::collections::HashMap; use tracing::instrument; -use crate::{ - nuts::{CurrencyUnit, State}, - Amount, Error, Wallet, -}; +use crate::nuts::nut00::ProofsMethods; +use crate::{nuts::CurrencyUnit, Amount, Error, Wallet}; impl Wallet { /// Total unspent balance of wallet #[instrument(skip(self))] pub async fn total_balance(&self) -> Result { - let proofs = self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Unspent]), - None, - ) - .await?; - let balance = Amount::try_sum(proofs.iter().map(|p| p.proof.amount))?; - - Ok(balance) + Ok(self.get_unspent_proofs().await?.total_amount()?) } /// Total pending balance #[instrument(skip(self))] pub async fn total_pending_balance(&self) -> Result, Error> { - let proofs = self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Pending]), - None, - ) - .await?; + let proofs = self.get_pending_proofs().await?; + // TODO If only the proofs for this wallet's unit are retrieved, why build a map with key = unit? let balances = proofs.iter().fold(HashMap::new(), |mut acc, proof| { - *acc.entry(proof.unit).or_insert(Amount::ZERO) += proof.proof.amount; + *acc.entry(self.unit.clone()).or_insert(Amount::ZERO) += proof.amount; acc }); @@ -49,18 +29,11 @@ impl Wallet { /// Total reserved balance #[instrument(skip(self))] pub async fn total_reserved_balance(&self) -> Result, Error> { - let proofs = self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Reserved]), - None, - ) - .await?; + let proofs = self.get_reserved_proofs().await?; + // TODO If only the proofs for this wallet's unit are retrieved, why build a map with key = unit? let balances = proofs.iter().fold(HashMap::new(), |mut acc, proof| { - *acc.entry(proof.unit).or_insert(Amount::ZERO) += proof.proof.amount; + *acc.entry(self.unit.clone()).or_insert(Amount::ZERO) += proof.amount; acc }); diff --git a/crates/cdk/src/wallet/client.rs b/crates/cdk/src/wallet/client.rs index d5b413ce..4296e6c2 100644 --- a/crates/cdk/src/wallet/client.rs +++ b/crates/cdk/src/wallet/client.rs @@ -1,5 +1,8 @@ //! Wallet client +use std::fmt::Debug; + +use async_trait::async_trait; use reqwest::Client; use serde_json::Value; use tracing::instrument; @@ -8,15 +11,12 @@ use url::Url; use super::Error; use crate::error::ErrorResponse; use crate::mint_url::MintUrl; -use crate::nuts::nut15::Mpp; use crate::nuts::{ - BlindedMessage, CheckStateRequest, CheckStateResponse, CurrencyUnit, Id, KeySet, KeysResponse, - KeysetResponse, MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, - MintBolt11Request, MintBolt11Response, MintInfo, MintQuoteBolt11Request, - MintQuoteBolt11Response, PreMintSecrets, Proof, PublicKey, RestoreRequest, RestoreResponse, - SwapRequest, SwapResponse, + CheckStateRequest, CheckStateResponse, Id, KeySet, KeysResponse, KeysetResponse, + MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, + MintBolt11Response, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, + RestoreResponse, SwapRequest, SwapResponse, }; -use crate::{Amount, Bolt11Invoice}; /// Http Client #[derive(Debug, Clone)] @@ -67,10 +67,14 @@ impl HttpClient { Ok(Self { inner: client }) } +} +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl HttpClientMethods for HttpClient { /// Get Active Mint Keys [NUT-01] #[instrument(skip(self), fields(mint_url = %mint_url))] - pub async fn get_mint_keys(&self, mint_url: MintUrl) -> Result, Error> { + async fn get_mint_keys(&self, mint_url: MintUrl) -> Result, Error> { let url = mint_url.join_paths(&["v1", "keys"])?; let keys = self.inner.get(url).send().await?.json::().await?; @@ -82,7 +86,7 @@ impl HttpClient { /// Get Keyset Keys [NUT-01] #[instrument(skip(self), fields(mint_url = %mint_url))] - pub async fn get_mint_keyset(&self, mint_url: MintUrl, keyset_id: Id) -> Result { + async fn get_mint_keyset(&self, mint_url: MintUrl, keyset_id: Id) -> Result { let url = mint_url.join_paths(&["v1", "keys", &keyset_id.to_string()])?; let keys = self.inner.get(url).send().await?.json::().await?; @@ -94,7 +98,7 @@ impl HttpClient { /// Get Keysets [NUT-02] #[instrument(skip(self), fields(mint_url = %mint_url))] - pub async fn get_mint_keysets(&self, mint_url: MintUrl) -> Result { + async fn get_mint_keysets(&self, mint_url: MintUrl) -> Result { let url = mint_url.join_paths(&["v1", "keysets"])?; let res = self.inner.get(url).send().await?.json::().await?; @@ -106,21 +110,13 @@ impl HttpClient { /// Mint Quote [NUT-04] #[instrument(skip(self), fields(mint_url = %mint_url))] - pub async fn post_mint_quote( + async fn post_mint_quote( &self, mint_url: MintUrl, - amount: Amount, - unit: CurrencyUnit, - description: Option, + request: MintQuoteBolt11Request, ) -> Result { let url = mint_url.join_paths(&["v1", "mint", "quote", "bolt11"])?; - let request = MintQuoteBolt11Request { - amount, - unit, - description, - }; - let res = self .inner .post(url) @@ -141,7 +137,7 @@ impl HttpClient { /// Mint Quote status #[instrument(skip(self), fields(mint_url = %mint_url))] - pub async fn get_mint_quote_status( + async fn get_mint_quote_status( &self, mint_url: MintUrl, quote_id: &str, @@ -160,20 +156,14 @@ impl HttpClient { } /// Mint Tokens [NUT-04] - #[instrument(skip(self, quote, premint_secrets), fields(mint_url = %mint_url))] - pub async fn post_mint( + #[instrument(skip(self, request), fields(mint_url = %mint_url))] + async fn post_mint( &self, mint_url: MintUrl, - quote: &str, - premint_secrets: PreMintSecrets, + request: MintBolt11Request, ) -> Result { let url = mint_url.join_paths(&["v1", "mint", "bolt11"])?; - let request = MintBolt11Request { - quote: quote.to_string(), - outputs: premint_secrets.blinded_messages(), - }; - let res = self .inner .post(url) @@ -191,23 +181,13 @@ impl HttpClient { /// Melt Quote [NUT-05] #[instrument(skip(self, request), fields(mint_url = %mint_url))] - pub async fn post_melt_quote( + async fn post_melt_quote( &self, mint_url: MintUrl, - unit: CurrencyUnit, - request: Bolt11Invoice, - mpp_amount: Option, + request: MeltQuoteBolt11Request, ) -> Result { let url = mint_url.join_paths(&["v1", "melt", "quote", "bolt11"])?; - let options = mpp_amount.map(|amount| Mpp { amount }); - - let request = MeltQuoteBolt11Request { - request, - unit, - options, - }; - let res = self .inner .post(url) @@ -225,7 +205,7 @@ impl HttpClient { /// Melt Quote Status #[instrument(skip(self), fields(mint_url = %mint_url))] - pub async fn get_melt_quote_status( + async fn get_melt_quote_status( &self, mint_url: MintUrl, quote_id: &str, @@ -242,22 +222,14 @@ impl HttpClient { /// Melt [NUT-05] /// [Nut-08] Lightning fee return if outputs defined - #[instrument(skip(self, quote, inputs, outputs), fields(mint_url = %mint_url))] - pub async fn post_melt( + #[instrument(skip(self, request), fields(mint_url = %mint_url))] + async fn post_melt( &self, mint_url: MintUrl, - quote: String, - inputs: Vec, - outputs: Option>, + request: MeltBolt11Request, ) -> Result { let url = mint_url.join_paths(&["v1", "melt", "bolt11"])?; - let request = MeltBolt11Request { - quote, - inputs, - outputs, - }; - let res = self .inner .post(url) @@ -278,9 +250,9 @@ impl HttpClient { } } - /// Split Token [NUT-06] + /// Swap Token [NUT-03] #[instrument(skip(self, swap_request), fields(mint_url = %mint_url))] - pub async fn post_swap( + async fn post_swap( &self, mint_url: MintUrl, swap_request: SwapRequest, @@ -304,7 +276,7 @@ impl HttpClient { /// Get Mint Info [NUT-06] #[instrument(skip(self), fields(mint_url = %mint_url))] - pub async fn get_mint_info(&self, mint_url: MintUrl) -> Result { + async fn get_mint_info(&self, mint_url: MintUrl) -> Result { let url = mint_url.join_paths(&["v1", "info"])?; let res = self.inner.get(url).send().await?.json::().await?; @@ -319,14 +291,13 @@ impl HttpClient { } /// Spendable check [NUT-07] - #[instrument(skip(self), fields(mint_url = %mint_url))] - pub async fn post_check_state( + #[instrument(skip(self, request), fields(mint_url = %mint_url))] + async fn post_check_state( &self, mint_url: MintUrl, - ys: Vec, + request: CheckStateRequest, ) -> Result { let url = mint_url.join_paths(&["v1", "checkstate"])?; - let request = CheckStateRequest { ys }; let res = self .inner @@ -345,7 +316,7 @@ impl HttpClient { /// Restore request [NUT-13] #[instrument(skip(self, request), fields(mint_url = %mint_url))] - pub async fn post_restore( + async fn post_restore( &self, mint_url: MintUrl, request: RestoreRequest, @@ -367,3 +338,84 @@ impl HttpClient { } } } + +/// Http Client Methods +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait HttpClientMethods: Debug { + /// Get Active Mint Keys [NUT-01] + async fn get_mint_keys(&self, mint_url: MintUrl) -> Result, Error>; + + /// Get Keyset Keys [NUT-01] + async fn get_mint_keyset(&self, mint_url: MintUrl, keyset_id: Id) -> Result; + + /// Get Keysets [NUT-02] + async fn get_mint_keysets(&self, mint_url: MintUrl) -> Result; + + /// Mint Quote [NUT-04] + async fn post_mint_quote( + &self, + mint_url: MintUrl, + request: MintQuoteBolt11Request, + ) -> Result; + + /// Mint Quote status + async fn get_mint_quote_status( + &self, + mint_url: MintUrl, + quote_id: &str, + ) -> Result; + + /// Mint Tokens [NUT-04] + async fn post_mint( + &self, + mint_url: MintUrl, + request: MintBolt11Request, + ) -> Result; + + /// Melt Quote [NUT-05] + async fn post_melt_quote( + &self, + mint_url: MintUrl, + request: MeltQuoteBolt11Request, + ) -> Result; + + /// Melt Quote Status + async fn get_melt_quote_status( + &self, + mint_url: MintUrl, + quote_id: &str, + ) -> Result; + + /// Melt [NUT-05] + /// [Nut-08] Lightning fee return if outputs defined + async fn post_melt( + &self, + mint_url: MintUrl, + request: MeltBolt11Request, + ) -> Result; + + /// Split Token [NUT-06] + async fn post_swap( + &self, + mint_url: MintUrl, + request: SwapRequest, + ) -> Result; + + /// Get Mint Info [NUT-06] + async fn get_mint_info(&self, mint_url: MintUrl) -> Result; + + /// Spendable check [NUT-07] + async fn post_check_state( + &self, + mint_url: MintUrl, + request: CheckStateRequest, + ) -> Result; + + /// Restore request [NUT-13] + async fn post_restore( + &self, + mint_url: MintUrl, + request: RestoreRequest, + ) -> Result; +} diff --git a/crates/cdk/src/wallet/melt.rs b/crates/cdk/src/wallet/melt.rs index 6d0f709b..4948ecd3 100644 --- a/crates/cdk/src/wallet/melt.rs +++ b/crates/cdk/src/wallet/melt.rs @@ -4,6 +4,7 @@ use lightning_invoice::Bolt11Invoice; use tracing::instrument; use crate::nuts::nut00::ProofsMethods; +use crate::nuts::{MeltBolt11Request, MeltQuoteBolt11Request, Mpp}; use crate::{ dhke::construct_proofs, nuts::{CurrencyUnit, MeltQuoteBolt11Response, PreMintSecrets, Proofs, State}, @@ -57,9 +58,17 @@ impl Wallet { _ => return Err(Error::UnitUnsupported), }; + let options = mpp.map(|amount| Mpp { amount }); + + let quote_request = MeltQuoteBolt11Request { + request: Bolt11Invoice::from_str(&request)?, + unit: self.unit.clone(), + options, + }; + let quote_res = self .client - .post_melt_quote(self.mint_url.clone(), self.unit, invoice, mpp) + .post_melt_quote(self.mint_url.clone(), quote_request) .await?; if quote_res.amount != amount { @@ -70,7 +79,7 @@ impl Wallet { id: quote_res.quote, amount, request, - unit: self.unit, + unit: self.unit.clone(), fee_reserve: quote_res.fee_reserve, state: quote_res.state, expiry: quote_res.expiry, @@ -146,15 +155,13 @@ impl Wallet { proofs_total - quote_info.amount, )?; - let melt_response = self - .client - .post_melt( - self.mint_url.clone(), - quote_id.to_string(), - proofs.clone(), - Some(premint_secrets.blinded_messages()), - ) - .await; + let request = MeltBolt11Request { + quote: quote_id.to_string(), + inputs: proofs.clone(), + outputs: Some(premint_secrets.blinded_messages()), + }; + + let melt_response = self.client.post_melt(self.mint_url.clone(), request).await; let melt_response = match melt_response { Ok(melt_response) => melt_response, @@ -226,7 +233,7 @@ impl Wallet { proof, self.mint_url.clone(), State::Unspent, - quote_info.unit, + quote_info.unit.clone(), ) }) .collect::, _>>()? @@ -286,7 +293,7 @@ impl Wallet { let inputs_needed_amount = quote_info.amount + quote_info.fee_reserve; - let available_proofs = self.get_proofs().await?; + let available_proofs = self.get_unspent_proofs().await?; let input_proofs = self .select_proofs_to_swap(inputs_needed_amount, available_proofs) diff --git a/crates/cdk/src/wallet/mint.rs b/crates/cdk/src/wallet/mint.rs index b429daaf..743044f0 100644 --- a/crates/cdk/src/wallet/mint.rs +++ b/crates/cdk/src/wallet/mint.rs @@ -2,6 +2,7 @@ use tracing::instrument; use super::MintQuote; use crate::nuts::nut00::ProofsMethods; +use crate::nuts::{MintBolt11Request, MintQuoteBolt11Request}; use crate::{ amount::SplitTarget, dhke::construct_proofs, @@ -45,7 +46,7 @@ impl Wallet { description: Option, ) -> Result { let mint_url = self.mint_url.clone(); - let unit = self.unit; + let unit = self.unit.clone(); // If we have a description, we check that the mint supports it. if description.is_some() { @@ -64,16 +65,22 @@ impl Wallet { } } + let request = MintQuoteBolt11Request { + amount, + unit: unit.clone(), + description, + }; + let quote_res = self .client - .post_mint_quote(mint_url.clone(), amount, unit, description) + .post_mint_quote(mint_url.clone(), request) .await?; let quote = MintQuote { mint_url, id: quote_res.quote.clone(), amount, - unit, + unit: unit.clone(), request: quote_res.request, state: quote_res.state, expiry: quote_res.expiry.unwrap_or(0), @@ -212,9 +219,14 @@ impl Wallet { )?, }; + let request = MintBolt11Request { + quote: quote_id.to_string(), + outputs: premint_secrets.blinded_messages(), + }; + let mint_res = self .client - .post_mint(self.mint_url.clone(), quote_id, premint_secrets.clone()) + .post_mint(self.mint_url.clone(), request) .await?; let keys = self.get_keyset_keys(active_keyset_id).await?; @@ -257,7 +269,7 @@ impl Wallet { proof, self.mint_url.clone(), State::Unspent, - quote_info.unit, + quote_info.unit.clone(), ) }) .collect::, _>>()?; diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index c0488c2f..7b8de868 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use bitcoin::bip32::Xpriv; use bitcoin::Network; +use client::HttpClientMethods; use tracing::instrument; use crate::amount::SplitTarget; @@ -55,7 +56,7 @@ pub struct Wallet { /// The targeted amount of proofs to have at each size pub target_proof_count: usize, xpriv: Xpriv, - client: HttpClient, + client: Arc, } impl Wallet { @@ -88,7 +89,7 @@ impl Wallet { Ok(Self { mint_url: MintUrl::from_str(mint_url)?, unit, - client: HttpClient::new(), + client: Arc::new(HttpClient::new()), localstore, xpriv, target_proof_count: target_proof_count.unwrap_or(3), @@ -96,8 +97,8 @@ impl Wallet { } /// Change HTTP client - pub fn set_client(&mut self, client: HttpClient) { - self.client = client; + pub fn set_client(&mut self, client: C) { + self.client = Arc::new(client); } /// Fee required for proof set @@ -181,7 +182,7 @@ impl Wallet { /// Get amounts needed to refill proof state #[instrument(skip(self))] pub async fn amounts_needed_for_state_target(&self) -> Result, Error> { - let unspent_proofs = self.get_proofs().await?; + let unspent_proofs = self.get_unspent_proofs().await?; let amounts_count: HashMap = unspent_proofs @@ -329,7 +330,12 @@ impl Wallet { let unspent_proofs = unspent_proofs .into_iter() .map(|proof| { - ProofInfo::new(proof, self.mint_url.clone(), State::Unspent, keyset.unit) + ProofInfo::new( + proof, + self.mint_url.clone(), + State::Unspent, + keyset.unit.clone(), + ) }) .collect::, _>>()?; diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index 6f3c3334..b0f048b2 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -55,7 +55,7 @@ impl MultiMintWallet { wallets: Arc::new(Mutex::new( wallets .into_iter() - .map(|w| (WalletKey::new(w.mint_url.clone(), w.unit), w)) + .map(|w| (WalletKey::new(w.mint_url.clone(), w.unit.clone()), w)) .collect(), )), } @@ -64,7 +64,7 @@ impl MultiMintWallet { /// Add wallet to MultiMintWallet #[instrument(skip(self, wallet))] pub async fn add_wallet(&self, wallet: Wallet) { - let wallet_key = WalletKey::new(wallet.mint_url.clone(), wallet.unit); + let wallet_key = WalletKey::new(wallet.mint_url.clone(), wallet.unit.clone()); let mut wallets = self.wallets.lock().await; @@ -125,8 +125,8 @@ impl MultiMintWallet { let mut mint_proofs = BTreeMap::new(); for (WalletKey { mint_url, unit: u }, wallet) in self.wallets.lock().await.iter() { - let wallet_proofs = wallet.get_proofs().await?; - mint_proofs.insert(mint_url.clone(), (wallet_proofs, *u)); + let wallet_proofs = wallet.get_unspent_proofs().await?; + mint_proofs.insert(mint_url.clone(), (wallet_proofs, u.clone())); } Ok(mint_proofs) } @@ -198,7 +198,7 @@ impl MultiMintWallet { let amount = wallet.check_all_mint_quotes().await?; amount_minted - .entry(wallet.unit) + .entry(wallet.unit.clone()) .and_modify(|b| *b += amount) .or_insert(amount); } @@ -246,7 +246,7 @@ impl MultiMintWallet { let mint_url = token_data.mint_url()?; // Check that all mints in tokes have wallets - let wallet_key = WalletKey::new(mint_url.clone(), unit); + let wallet_key = WalletKey::new(mint_url.clone(), unit.clone()); if !self.has(&wallet_key).await { return Err(Error::UnknownWallet(wallet_key.clone())); } diff --git a/crates/cdk/src/wallet/proofs.rs b/crates/cdk/src/wallet/proofs.rs index 5ea4f53b..fd67015a 100644 --- a/crates/cdk/src/wallet/proofs.rs +++ b/crates/cdk/src/wallet/proofs.rs @@ -3,9 +3,10 @@ use std::collections::HashSet; use tracing::instrument; use crate::nuts::nut00::ProofsMethods; +use crate::nuts::CheckStateRequest; use crate::{ amount::SplitTarget, - nuts::{Proof, ProofState, Proofs, PublicKey, State}, + nuts::{Proof, ProofState, Proofs, PublicKey, SpendingConditions, State}, types::ProofInfo, Amount, Error, Wallet, }; @@ -13,48 +14,36 @@ use crate::{ impl Wallet { /// Get unspent proofs for mint #[instrument(skip(self))] - pub async fn get_proofs(&self) -> Result { - Ok(self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Unspent]), - None, - ) - .await? - .into_iter() - .map(|p| p.proof) - .collect()) + pub async fn get_unspent_proofs(&self) -> Result { + self.get_proofs_with(Some(vec![State::Unspent]), None).await } /// Get pending [`Proofs`] #[instrument(skip(self))] pub async fn get_pending_proofs(&self) -> Result { - Ok(self - .localstore - .get_proofs( - Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Pending]), - None, - ) - .await? - .into_iter() - .map(|p| p.proof) - .collect()) + self.get_proofs_with(Some(vec![State::Pending]), None).await } /// Get reserved [`Proofs`] #[instrument(skip(self))] pub async fn get_reserved_proofs(&self) -> Result { + self.get_proofs_with(Some(vec![State::Reserved]), None) + .await + } + + /// Get this wallet's [Proofs] that match the args + pub async fn get_proofs_with( + &self, + state: Option>, + spending_conditions: Option>, + ) -> Result { Ok(self .localstore .get_proofs( Some(self.mint_url.clone()), - Some(self.unit), - Some(vec![State::Reserved]), - None, + Some(self.unit.clone()), + state, + spending_conditions, ) .await? .into_iter() @@ -77,7 +66,7 @@ impl Wallet { let spendable = self .client - .post_check_state(self.mint_url.clone(), proof_ys) + .post_check_state(self.mint_url.clone(), CheckStateRequest { ys: proof_ys }) .await? .states; @@ -98,7 +87,10 @@ impl Wallet { pub async fn check_proofs_spent(&self, proofs: Proofs) -> Result, Error> { let spendable = self .client - .post_check_state(self.mint_url.clone(), proofs.ys()?) + .post_check_state( + self.mint_url.clone(), + CheckStateRequest { ys: proofs.ys()? }, + ) .await?; let spent_ys: Vec<_> = spendable .states @@ -123,7 +115,7 @@ impl Wallet { .localstore .get_proofs( Some(self.mint_url.clone()), - Some(self.unit), + Some(self.unit.clone()), Some(vec![State::Pending, State::Reserved]), None, ) diff --git a/crates/cdk/src/wallet/receive.rs b/crates/cdk/src/wallet/receive.rs index d5999232..2a594b1f 100644 --- a/crates/cdk/src/wallet/receive.rs +++ b/crates/cdk/src/wallet/receive.rs @@ -111,7 +111,7 @@ impl Wallet { let proofs_info = proofs .clone() .into_iter() - .map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Pending, self.unit)) + .map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Pending, self.unit.clone())) .collect::, _>>()?; self.localstore .update_proofs(proofs_info.clone(), vec![]) @@ -150,7 +150,7 @@ impl Wallet { let recv_proof_infos = recv_proofs .into_iter() - .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, self.unit)) + .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, self.unit.clone())) .collect::, _>>()?; self.localstore .update_proofs( diff --git a/crates/cdk/src/wallet/send.rs b/crates/cdk/src/wallet/send.rs index f9a62b9a..0c6e7b79 100644 --- a/crates/cdk/src/wallet/send.rs +++ b/crates/cdk/src/wallet/send.rs @@ -16,7 +16,12 @@ impl Wallet { let ys = proofs.ys()?; self.localstore.reserve_proofs(ys).await?; - Ok(Token::new(self.mint_url.clone(), proofs, memo, self.unit)) + Ok(Token::new( + self.mint_url.clone(), + proofs, + memo, + self.unit.clone(), + )) } /// Send @@ -43,19 +48,14 @@ impl Wallet { } } - let mint_url = &self.mint_url; - let unit = &self.unit; let available_proofs = self - .localstore - .get_proofs( - Some(mint_url.clone()), - Some(*unit), + .get_proofs_with( Some(vec![State::Unspent]), conditions.clone().map(|c| vec![c]), ) .await?; - let (available_proofs, proofs_sum) = available_proofs.into_iter().map(|p| p.proof).fold( + let (available_proofs, proofs_sum) = available_proofs.into_iter().fold( (Vec::new(), Amount::ZERO), |(mut acc1, mut acc2), p| { acc2 += p.amount; @@ -66,20 +66,9 @@ impl Wallet { let available_proofs = if proofs_sum < amount { match &conditions { Some(conditions) => { - let available_proofs = self - .localstore - .get_proofs( - Some(mint_url.clone()), - Some(*unit), - Some(vec![State::Unspent]), - None, - ) - .await?; - - let available_proofs = available_proofs.into_iter().map(|p| p.proof).collect(); + let unspent_proofs = self.get_unspent_proofs().await?; - let proofs_to_swap = - self.select_proofs_to_swap(amount, available_proofs).await?; + let proofs_to_swap = self.select_proofs_to_swap(amount, unspent_proofs).await?; let proofs_with_conditions = self .swap( @@ -90,12 +79,10 @@ impl Wallet { include_fees, ) .await?; - proofs_with_conditions.ok_or(Error::InsufficientFunds)? + proofs_with_conditions.ok_or(Error::InsufficientFunds) } - None => { - return Err(Error::InsufficientFunds); - } - } + None => Err(Error::InsufficientFunds), + }? } else { available_proofs }; diff --git a/crates/cdk/src/wallet/swap.rs b/crates/cdk/src/wallet/swap.rs index 043d37c5..650ceac9 100644 --- a/crates/cdk/src/wallet/swap.rs +++ b/crates/cdk/src/wallet/swap.rs @@ -111,7 +111,9 @@ impl Wallet { let send_proofs_info = proofs_to_send .clone() .into_iter() - .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Reserved, *unit)) + .map(|proof| { + ProofInfo::new(proof, mint_url.clone(), State::Reserved, unit.clone()) + }) .collect::, _>>()?; added_proofs = send_proofs_info; @@ -126,7 +128,7 @@ impl Wallet { let keep_proofs = change_proofs .into_iter() - .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, *unit)) + .map(|proof| ProofInfo::new(proof, mint_url.clone(), State::Unspent, unit.clone())) .collect::, _>>()?; added_proofs.extend(keep_proofs); @@ -154,7 +156,7 @@ impl Wallet { .localstore .get_proofs( Some(self.mint_url.clone()), - Some(self.unit), + Some(self.unit.clone()), Some(vec![State::Unspent]), None, ) diff --git a/flake.lock b/flake.lock index 85d425eb..b40547ba 100644 --- a/flake.lock +++ b/flake.lock @@ -57,11 +57,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1727540905, - "narHash": "sha256-40J9tW7Y794J7Uw4GwcAKlMxlX2xISBl6IBigo83ih8=", + "lastModified": 1730963269, + "narHash": "sha256-rz30HrFYCHiWEBCKHMffHbMdWJ35hEkcRVU0h7ms3x0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "fbca5e745367ae7632731639de5c21f29c8744ed", + "rev": "83fb6c028368e465cd19bb127b86f971a5e41ebc", "type": "github" }, "original": { @@ -73,11 +73,11 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1720386169, - "narHash": "sha256-NGKVY4PjzwAa4upkGtAMz1npHGoRzWotlSnVlqI40mo=", + "lastModified": 1730741070, + "narHash": "sha256-edm8WG19kWozJ/GqyYx2VjW99EdhjKwbY3ZwdlPAAlo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "194846768975b7ad2c4988bdb82572c00222c0d7", + "rev": "d063c1dd113c91ab27959ba540c0d9753409edf3", "type": "github" }, "original": { @@ -89,11 +89,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1719082008, - "narHash": "sha256-jHJSUH619zBQ6WdC21fFAlDxHErKVDJ5fpN0Hgx4sjs=", + "lastModified": 1730768919, + "narHash": "sha256-8AKquNnnSaJRXZxc5YmF/WfmxiHX6MMZZasRP6RRQkE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9693852a2070b398ee123a329e68f0dab5526681", + "rev": "a04d33c0c3f1a59a2c1cb0c6e34cd24500e5a1dc", "type": "github" }, "original": { @@ -111,11 +111,11 @@ "nixpkgs-stable": "nixpkgs-stable" }, "locked": { - "lastModified": 1727514110, - "narHash": "sha256-0YRcOxJG12VGDFH8iS8pJ0aYQQUAgo/r3ZAL+cSh9nk=", + "lastModified": 1730814269, + "narHash": "sha256-fWPHyhYE6xvMI1eGY3pwBTq85wcy1YXqdzTZF+06nOg=", "owner": "cachix", "repo": "pre-commit-hooks.nix", - "rev": "85f7a7177c678de68224af3402ab8ee1bcee25c8", + "rev": "d70155fdc00df4628446352fc58adc640cd705c2", "type": "github" }, "original": { @@ -139,11 +139,11 @@ ] }, "locked": { - "lastModified": 1727663505, - "narHash": "sha256-83j/GrHsx8GFUcQofKh+PRPz6pz8sxAsZyT/HCNdey8=", + "lastModified": 1731119076, + "narHash": "sha256-2eVhmocCZHJlFAz6Mt3EwPdFFVAtGgIySJc1EHQVxcc=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "c2099c6c7599ea1980151b8b6247a8f93e1806ee", + "rev": "23c4b3ba5f806fcf25d5a3b6b54fa0d07854c032", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index b4078557..d65bc268 100644 --- a/flake.nix +++ b/flake.nix @@ -37,7 +37,7 @@ # Toolchains # latest stable - stable_toolchain = pkgs.rust-bin.stable.latest.default.override { + stable_toolchain = pkgs.rust-bin.stable."1.82.0".default.override { targets = [ "wasm32-unknown-unknown" ]; # wasm }; @@ -63,7 +63,7 @@ pkg-config curl just - protobuf3_20 + protobuf nixpkgs-fmt rust-analyzer typos @@ -139,6 +139,7 @@ cargo update -p bumpalo --precise 3.12.0 cargo update -p moka --precise 0.11.1 cargo update -p triomphe --precise 0.1.11 + cargo update -p url --precise 2.5.2 "; buildInputs = buildInputs ++ WASMInputs ++ [ msrv_toolchain ]; inherit nativeBuildInputs; @@ -160,6 +161,8 @@ cargo update -p tokio-stream --precise 0.1.15 cargo update -p serde_with --precise 3.1.0 cargo update -p reqwest --precise 0.12.4 + cargo update -p url --precise 2.5.2 + cargo update -p allocator-api2 --precise 0.2.18 "; buildInputs = buildInputs ++ WASMInputs ++ [ db_msrv_toolchain ]; inherit nativeBuildInputs; diff --git a/justfile b/justfile index 552d46e6..4e36977e 100644 --- a/justfile +++ b/justfile @@ -45,7 +45,7 @@ test: build if [ ! -f Cargo.toml ]; then cd {{invocation_directory()}} fi - cargo test + cargo test --lib # run `cargo clippy` on everything clippy *ARGS="--locked --offline --workspace --all-targets": diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..7f4575e0 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel="1.82.0" +components = ["rustfmt", "clippy", "rust-analyzer"] + diff --git a/rustfmt.toml b/rustfmt.toml index 9b155f2e..547421e7 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,5 +1,9 @@ tab_spaces = 4 -max_width = 100 newline_style = "Auto" reorder_imports = true reorder_modules = true +reorder_impl_items = false +indent_style = "Block" +normalize_comments = false +imports_granularity = "Module" +group_imports = "StdExternalCrate"