|
@@ -0,0 +1,626 @@
|
|
|
|
|
+//! Utilities for paying NUT-18 Payment Requests.
|
|
|
|
|
+//!
|
|
|
|
|
+//! This module prepares and broadcasts payments for Cashu NUT-18 payment requests using either
|
|
|
|
|
+//! Nostr or HTTP transports when available. If no transport is present in the request, an error
|
|
|
|
|
+//! is returned so callers can handle alternative delivery mechanisms explicitly.
|
|
|
|
|
+
|
|
|
|
|
+use std::str::FromStr;
|
|
|
|
|
+
|
|
|
|
|
+use anyhow::Result;
|
|
|
|
|
+use bitcoin::hashes::sha256::Hash as Sha256Hash;
|
|
|
|
|
+use cdk_common::{Amount, PaymentRequest, PaymentRequestPayload, TransportType};
|
|
|
|
|
+#[cfg(feature = "nostr")]
|
|
|
|
|
+use nostr_sdk::nips::nip19::Nip19Profile;
|
|
|
|
|
+#[cfg(feature = "nostr")]
|
|
|
|
|
+use nostr_sdk::prelude::*;
|
|
|
|
|
+#[cfg(feature = "nostr")]
|
|
|
|
|
+use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys, ToBech32};
|
|
|
|
|
+use reqwest::Client;
|
|
|
|
|
+
|
|
|
|
|
+use crate::error::Error;
|
|
|
|
|
+use crate::nuts::nut11::{Conditions, SigFlag, SpendingConditions};
|
|
|
|
|
+use crate::nuts::nut18::Nut10SecretRequest;
|
|
|
|
|
+use crate::nuts::{CurrencyUnit, Transport};
|
|
|
|
|
+#[cfg(feature = "nostr")]
|
|
|
|
|
+use crate::wallet::ReceiveOptions;
|
|
|
|
|
+use crate::wallet::{MultiMintWallet, SendOptions};
|
|
|
|
|
+use crate::Wallet;
|
|
|
|
|
+
|
|
|
|
|
+impl Wallet {
|
|
|
|
|
+ /// Pay a NUT-18 PaymentRequest using a specific wallet.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// - If the request contains a Nostr or HttpPost transport, it will try those (preferring Nostr).
|
|
|
|
|
+ /// - If no usable transport is present, this returns an error.
|
|
|
|
|
+ /// - If the request has no amount, a `custom_amount` must be provided.
|
|
|
|
|
+ pub async fn pay_request(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ payment_request: PaymentRequest,
|
|
|
|
|
+ custom_amount: Option<Amount>,
|
|
|
|
|
+ ) -> Result<(), Error> {
|
|
|
|
|
+ let amount = match payment_request.amount {
|
|
|
|
|
+ Some(amount) => amount,
|
|
|
|
|
+ None => match custom_amount {
|
|
|
|
|
+ Some(a) => a,
|
|
|
|
|
+ None => return Err(Error::AmountUndefined),
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let transports = payment_request.transports.clone();
|
|
|
|
|
+
|
|
|
|
|
+ // Prefer Nostr to avoid revealing IP, fall back to HTTP POST.
|
|
|
|
|
+ let transport = transports
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .find(|t| t._type == TransportType::Nostr)
|
|
|
|
|
+ .or_else(|| {
|
|
|
|
|
+ transports
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .find(|t| t._type == TransportType::HttpPost)
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ let prepared_send = self
|
|
|
|
|
+ .prepare_send(
|
|
|
|
|
+ amount,
|
|
|
|
|
+ SendOptions {
|
|
|
|
|
+ include_fee: true,
|
|
|
|
|
+ ..Default::default()
|
|
|
|
|
+ },
|
|
|
|
|
+ )
|
|
|
|
|
+ .await?;
|
|
|
|
|
+
|
|
|
|
|
+ let token = prepared_send.confirm(None).await?;
|
|
|
|
|
+
|
|
|
|
|
+ // We need the keysets information to properly convert from token proof to proof
|
|
|
|
|
+ let keysets_info = match self.localstore.get_mint_keysets(token.mint_url()?).await? {
|
|
|
|
|
+ Some(keysets_info) => keysets_info,
|
|
|
|
|
+ None => self.load_mint_keysets().await?,
|
|
|
|
|
+ };
|
|
|
|
|
+ let proofs = token.proofs(&keysets_info)?;
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(transport) = transport {
|
|
|
|
|
+ let payload = PaymentRequestPayload {
|
|
|
|
|
+ id: payment_request.payment_id.clone(),
|
|
|
|
|
+ memo: None,
|
|
|
|
|
+ mint: self.mint_url.clone(),
|
|
|
|
|
+ unit: self.unit.clone(),
|
|
|
|
|
+ proofs,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ match transport._type {
|
|
|
|
|
+ TransportType::Nostr => {
|
|
|
|
|
+ #[cfg(feature = "nostr")]
|
|
|
|
|
+ {
|
|
|
|
|
+ let keys = Keys::generate();
|
|
|
|
|
+ let client = NostrClient::new(keys);
|
|
|
|
|
+ let nprofile = Nip19Profile::from_bech32(&transport.target)
|
|
|
|
|
+ .map_err(|e| Error::Custom(format!("Invalid nprofile: {e}")))?;
|
|
|
|
|
+
|
|
|
|
|
+ let rumor = EventBuilder::new(
|
|
|
|
|
+ nostr_sdk::Kind::from_u16(14),
|
|
|
|
|
+ serde_json::to_string(&payload)
|
|
|
|
|
+ .map_err(|e| Error::Custom(format!("Serialize payload: {e}")))?,
|
|
|
|
|
+ )
|
|
|
|
|
+ .build(nprofile.public_key);
|
|
|
|
|
+ let relays = nprofile.relays;
|
|
|
|
|
+
|
|
|
|
|
+ for relay in relays.iter() {
|
|
|
|
|
+ client
|
|
|
|
|
+ .add_write_relay(relay)
|
|
|
|
|
+ .await
|
|
|
|
|
+ .map_err(|e| Error::Custom(format!("Add relay {relay}: {e}")))?;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ client.connect().await;
|
|
|
|
|
+
|
|
|
|
|
+ let gift_wrap = client
|
|
|
|
|
+ .gift_wrap_to(relays, &nprofile.public_key, rumor, None)
|
|
|
|
|
+ .await
|
|
|
|
|
+ .map_err(|e| Error::Custom(format!("Publish Nostr event: {e}")))?;
|
|
|
|
|
+
|
|
|
|
|
+ println!(
|
|
|
|
|
+ "Published event {} successfully to {}",
|
|
|
|
|
+ gift_wrap.val,
|
|
|
|
|
+ gift_wrap
|
|
|
|
|
+ .success
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .map(|s| s.to_string())
|
|
|
|
|
+ .collect::<Vec<_>>()
|
|
|
|
|
+ .join(", ")
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ if !gift_wrap.failed.is_empty() {
|
|
|
|
|
+ println!(
|
|
|
|
|
+ "Could not publish to {}",
|
|
|
|
|
+ gift_wrap
|
|
|
|
|
+ .failed
|
|
|
|
|
+ .keys()
|
|
|
|
|
+ .map(|relay| relay.to_string())
|
|
|
|
|
+ .collect::<Vec<_>>()
|
|
|
|
|
+ .join(", ")
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+ #[cfg(not(feature = "nostr"))]
|
|
|
|
|
+ Err(Error::Custom(
|
|
|
|
|
+ "Nostr is not enabled in this build".to_string(),
|
|
|
|
|
+ ))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ TransportType::HttpPost => {
|
|
|
|
|
+ let client = Client::new();
|
|
|
|
|
+
|
|
|
|
|
+ let res = client
|
|
|
|
|
+ .post(transport.target.clone())
|
|
|
|
|
+ .json(&payload)
|
|
|
|
|
+ .send()
|
|
|
|
|
+ .await
|
|
|
|
|
+ .map_err(|e| Error::HttpError(None, e.to_string()))?;
|
|
|
|
|
+
|
|
|
|
|
+ let status = res.status();
|
|
|
|
|
+ if status.is_success() {
|
|
|
|
|
+ println!("Successfully posted payment");
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let body = res.text().await.unwrap_or_default();
|
|
|
|
|
+ Err(Error::HttpError(Some(status.as_u16()), body))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // If no transport is available, return an error instead of printing the token
|
|
|
|
|
+ Err(Error::Custom(
|
|
|
|
|
+ "No transport available in payment request".to_string(),
|
|
|
|
|
+ ))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Parameters for creating a PaymentRequest
|
|
|
|
|
+///
|
|
|
|
|
+/// This mirrors the CLI inputs and is used by `create_request` to build a
|
|
|
|
|
+/// NUT-18 PaymentRequest. When `transport` is set to `nostr`, the function
|
|
|
|
|
+/// also returns a `NostrWaitInfo` that can be passed to `wait_for_nostr_payment`.
|
|
|
|
|
+#[derive(Debug, Clone)]
|
|
|
|
|
+pub struct CreateRequestParams {
|
|
|
|
|
+ /// Optional amount to request (in the smallest unit for the chosen currency unit)
|
|
|
|
|
+ pub amount: Option<u64>,
|
|
|
|
|
+ /// Currency unit string (e.g., "sat")
|
|
|
|
|
+ pub unit: String,
|
|
|
|
|
+ /// Optional human-readable description for the request
|
|
|
|
|
+ pub description: Option<String>,
|
|
|
|
|
+ /// Optional set of public keys for P2PK spending conditions (multisig supported)
|
|
|
|
|
+ pub pubkeys: Option<Vec<String>>, // multiple P2PK pubkeys
|
|
|
|
|
+ /// Required number of signatures if `pubkeys` is provided (defaults typically to 1)
|
|
|
|
|
+ pub num_sigs: u64, // required signatures for P2PK
|
|
|
|
|
+ /// Optional HTLC hash condition (mutually exclusive with `preimage`)
|
|
|
|
|
+ pub hash: Option<String>, // HTLC hash
|
|
|
|
|
+ /// Optional HTLC preimage (mutually exclusive with `hash`)
|
|
|
|
|
+ pub preimage: Option<String>, // HTLC preimage
|
|
|
|
|
+ /// Transport type for the request: "nostr", "http", or "none"
|
|
|
|
|
+ pub transport: String, // "nostr", "http", or "none"
|
|
|
|
|
+ /// Target URL for HTTP transport (required if `transport == http`)
|
|
|
|
|
+ pub http_url: Option<String>, // when transport == http
|
|
|
|
|
+ /// List of Nostr relay URLs to include in the nprofile (used if `transport == nostr`)
|
|
|
|
|
+ pub nostr_relays: Option<Vec<String>>, // when transport == nostr
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Extra information needed to wait for an incoming Nostr payment
|
|
|
|
|
+///
|
|
|
|
|
+/// Returned by `create_request` when the transport is `nostr`. Pass this to
|
|
|
|
|
+/// `wait_for_nostr_payment` to connect, subscribe, and receive the incoming
|
|
|
|
|
+/// payment on the specified relays.
|
|
|
|
|
+#[cfg(feature = "nostr")]
|
|
|
|
|
+#[derive(Debug, Clone)]
|
|
|
|
|
+pub struct NostrWaitInfo {
|
|
|
|
|
+ /// Ephemeral keys used to connect to relays and unwrap the gift-wrapped event
|
|
|
|
|
+ pub keys: Keys,
|
|
|
|
|
+ /// Nostr relays to read from while waiting for the payment
|
|
|
|
|
+ pub relays: Vec<String>,
|
|
|
|
|
+ /// The recipient public key to subscribe to for incoming events
|
|
|
|
|
+ pub pubkey: nostr_sdk::PublicKey,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl MultiMintWallet {
|
|
|
|
|
+ /// Derive enforceable NUT-10 spending conditions from high-level request params.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Why:
|
|
|
|
|
+ /// - Centralizes translation of CLI/SDK inputs (P2PK multisig and HTLC variants) into
|
|
|
|
|
+ /// a single, canonical `SpendingConditions` shape so requests are consistent.
|
|
|
|
|
+ /// - Prevents ambiguous construction by capping `num_sigs` to the number of provided keys
|
|
|
|
|
+ /// and rejecting malformed hashes/inputs early.
|
|
|
|
|
+ /// - Encourages safe defaults by selecting `SigFlag::SigInputs` and composing conditions
|
|
|
|
|
+ /// that can be verified by recipients and mints.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Behavior notes (rationale):
|
|
|
|
|
+ /// - If no P2PK or HTLC data is given, returns `Ok(None)` so callers emit a plain request
|
|
|
|
|
+ /// without additional constraints.
|
|
|
|
|
+ /// - With `pubkeys` only, constructs P2PK-style conditions where the first key is used as
|
|
|
|
|
+ /// the primary spend key and the remainder contribute to multisig according to `num_sigs`.
|
|
|
|
|
+ /// - With `hash` or `preimage`, constructs an HTLC condition, optionally embedding P2PK
|
|
|
|
|
+ /// conditions to require signatures in addition to the hash lock.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Errors:
|
|
|
|
|
+ /// - Invalid SHA-256 `hash` strings or invalid HTLC/P2PK parameterizations surface as errors
|
|
|
|
|
+ /// from parsing and `SpendingConditions` constructors.
|
|
|
|
|
+ fn get_pr_spending_conditions(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ params: &CreateRequestParams,
|
|
|
|
|
+ ) -> Result<Option<SpendingConditions>, Error> {
|
|
|
|
|
+ // Spending conditions
|
|
|
|
|
+ let spending_conditions: Option<SpendingConditions> =
|
|
|
|
|
+ if let Some(pubkey_strings) = ¶ms.pubkeys {
|
|
|
|
|
+ // parse pubkeys
|
|
|
|
|
+ let mut parsed_pubkeys = Vec::new();
|
|
|
|
|
+ for p in pubkey_strings {
|
|
|
|
|
+ if let Ok(pk) = crate::nuts::nut01::PublicKey::from_str(p) {
|
|
|
|
|
+ parsed_pubkeys.push(pk);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if parsed_pubkeys.is_empty() {
|
|
|
|
|
+ None
|
|
|
|
|
+ } else {
|
|
|
|
|
+ let num_sigs = params.num_sigs.min(parsed_pubkeys.len() as u64);
|
|
|
|
|
+
|
|
|
|
|
+ if let Some(hash_str) = ¶ms.hash {
|
|
|
|
|
+ let conditions = Conditions {
|
|
|
|
|
+ locktime: None,
|
|
|
|
|
+ pubkeys: Some(parsed_pubkeys),
|
|
|
|
|
+ refund_keys: None,
|
|
|
|
|
+ num_sigs: Some(num_sigs),
|
|
|
|
|
+ sig_flag: SigFlag::SigInputs,
|
|
|
|
|
+ num_sigs_refund: None,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ match Sha256Hash::from_str(hash_str) {
|
|
|
|
|
+ Ok(hash) => Some(SpendingConditions::HTLCConditions {
|
|
|
|
|
+ data: hash,
|
|
|
|
|
+ conditions: Some(conditions),
|
|
|
|
|
+ }),
|
|
|
|
|
+ Err(err) => {
|
|
|
|
|
+ return Err(Error::Custom(format!("Error parsing hash: {err}")))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if let Some(preimage) = ¶ms.preimage {
|
|
|
|
|
+ let conditions = Conditions {
|
|
|
|
|
+ locktime: None,
|
|
|
|
|
+ pubkeys: Some(parsed_pubkeys),
|
|
|
|
|
+ refund_keys: None,
|
|
|
|
|
+ num_sigs: Some(num_sigs),
|
|
|
|
|
+ sig_flag: SigFlag::SigInputs,
|
|
|
|
|
+ num_sigs_refund: None,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ Some(SpendingConditions::new_htlc(
|
|
|
|
|
+ preimage.to_string(),
|
|
|
|
|
+ Some(conditions),
|
|
|
|
|
+ )?)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ Some(SpendingConditions::new_p2pk(
|
|
|
|
|
+ *parsed_pubkeys.first().expect("not empty"),
|
|
|
|
|
+ Some(Conditions {
|
|
|
|
|
+ locktime: None,
|
|
|
|
|
+ pubkeys: Some(parsed_pubkeys[1..].to_vec()),
|
|
|
|
|
+ refund_keys: None,
|
|
|
|
|
+ num_sigs: Some(num_sigs),
|
|
|
|
|
+ sig_flag: SigFlag::SigInputs,
|
|
|
|
|
+ num_sigs_refund: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ ))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if let Some(hash_str) = ¶ms.hash {
|
|
|
|
|
+ match Sha256Hash::from_str(hash_str) {
|
|
|
|
|
+ Ok(hash) => Some(SpendingConditions::HTLCConditions {
|
|
|
|
|
+ data: hash,
|
|
|
|
|
+ conditions: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ Err(err) => return Err(Error::Custom(format!("Error parsing hash: {err}"))),
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if let Some(preimage) = ¶ms.preimage {
|
|
|
|
|
+ Some(SpendingConditions::new_htlc(preimage.to_string(), None)?)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ None
|
|
|
|
|
+ };
|
|
|
|
|
+ Ok(spending_conditions)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Create a NUT-18 PaymentRequest from high-level parameters.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Why:
|
|
|
|
|
+ /// - Ensures the CLI and SDKs construct requests consistently using wallet context.
|
|
|
|
|
+ /// - Advertises available mints for the chosen unit so payers can select compatible proofs.
|
|
|
|
|
+ /// - Optionally embeds a transport; Nostr is preferred to reduce IP exposure for the payer.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Behavior summary (focus on rationale rather than steps):
|
|
|
|
|
+ /// - Uses `unit` to discover mints with balances as a hint to senders (helps route payments without leaking more data than necessary).
|
|
|
|
|
+ /// - Translates P2PK/multisig and HTLC inputs (pubkeys/num_sigs/hash/preimage) into a NUT-10 secret request so the receiver can enforce spending constraints.
|
|
|
|
|
+ /// - For `transport == "nostr"`, generates ephemeral keys and an nprofile pointing at the chosen relays; returns `NostrWaitInfo` so callers can wait for the incoming payment without coupling construction and reception logic.
|
|
|
|
|
+ /// - For `transport == "http"`, attaches the provided endpoint; for `none` or unknown, omits transports to let the caller deliver out-of-band.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Returns:
|
|
|
|
|
+ /// - `(PaymentRequest, Some(NostrWaitInfo))` when `transport == "nostr"`.
|
|
|
|
|
+ /// - `(PaymentRequest, None)` otherwise.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Errors when:
|
|
|
|
|
+ /// - `unit` cannot be parsed, relay URLs are invalid, or P2PK/HTLC parameters are malformed.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Notes:
|
|
|
|
|
+ /// - Sets `single_use = true` to discourage replays.
|
|
|
|
|
+ /// - Ephemeral Nostr keys are intentional; keep `NostrWaitInfo` only as long as needed for reception.
|
|
|
|
|
+ #[cfg(feature = "nostr")]
|
|
|
|
|
+ pub async fn create_request(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ params: CreateRequestParams,
|
|
|
|
|
+ ) -> Result<(PaymentRequest, Option<NostrWaitInfo>), Error> {
|
|
|
|
|
+ // Collect available mints for the selected unit
|
|
|
|
|
+ let mints = self
|
|
|
|
|
+ .get_balances(&CurrencyUnit::from_str(¶ms.unit)?)
|
|
|
|
|
+ .await?
|
|
|
|
|
+ .keys()
|
|
|
|
|
+ .cloned()
|
|
|
|
|
+ .collect::<Vec<_>>();
|
|
|
|
|
+
|
|
|
|
|
+ // Transports
|
|
|
|
|
+ let transport_type = params.transport.to_lowercase();
|
|
|
|
|
+ let (transports, nostr_info): (Vec<Transport>, Option<NostrWaitInfo>) =
|
|
|
|
|
+ match transport_type.as_str() {
|
|
|
|
|
+ "nostr" => {
|
|
|
|
|
+ let keys = Keys::generate();
|
|
|
|
|
+ let relays = if let Some(custom_relays) = ¶ms.nostr_relays {
|
|
|
|
|
+ if !custom_relays.is_empty() {
|
|
|
|
|
+ custom_relays.clone()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return Err(Error::Custom("No relays provided".to_string()));
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return Err(Error::Custom("No relays provided".to_string()));
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Parse relay URLs for nprofile
|
|
|
|
|
+ let relay_urls = relays
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .map(|r| RelayUrl::parse(r))
|
|
|
|
|
+ .collect::<Result<Vec<_>, _>>()
|
|
|
|
|
+ .map_err(|e| Error::Custom(format!("Couldn't parse relays: {e}")))?;
|
|
|
|
|
+
|
|
|
|
|
+ let nprofile =
|
|
|
|
|
+ nostr_sdk::nips::nip19::Nip19Profile::new(keys.public_key, relay_urls);
|
|
|
|
|
+ let nostr_transport = Transport {
|
|
|
|
|
+ _type: TransportType::Nostr,
|
|
|
|
|
+ target: nprofile.to_bech32().map_err(|e| {
|
|
|
|
|
+ Error::Custom(format!("Couldn't convert nprofile to bech32: {e}"))
|
|
|
|
|
+ })?,
|
|
|
|
|
+ tags: Some(vec![vec!["n".to_string(), "17".to_string()]]),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ (
|
|
|
|
|
+ vec![nostr_transport],
|
|
|
|
|
+ Some(NostrWaitInfo {
|
|
|
|
|
+ keys,
|
|
|
|
|
+ relays,
|
|
|
|
|
+ pubkey: nprofile.public_key,
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ "http" => {
|
|
|
|
|
+ if let Some(url) = ¶ms.http_url {
|
|
|
|
|
+ let http_transport = Transport {
|
|
|
|
|
+ _type: TransportType::HttpPost,
|
|
|
|
|
+ target: url.clone(),
|
|
|
|
|
+ tags: None,
|
|
|
|
|
+ };
|
|
|
|
|
+ (vec![http_transport], None)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // No URL provided, skip transport
|
|
|
|
|
+ (vec![], None)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ "none" => (vec![], None),
|
|
|
|
|
+ _ => (vec![], None),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let nut10 = self
|
|
|
|
|
+ .get_pr_spending_conditions(¶ms)?
|
|
|
|
|
+ .map(Nut10SecretRequest::from);
|
|
|
|
|
+
|
|
|
|
|
+ let req = PaymentRequest {
|
|
|
|
|
+ payment_id: None,
|
|
|
|
|
+ amount: params.amount.map(Amount::from),
|
|
|
|
|
+ unit: Some(CurrencyUnit::from_str(¶ms.unit)?),
|
|
|
|
|
+ single_use: Some(true),
|
|
|
|
|
+ mints: Some(mints),
|
|
|
|
|
+ description: params.description,
|
|
|
|
|
+ transports,
|
|
|
|
|
+ nut10,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ Ok((req, nostr_info))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Create a NUT-18 PaymentRequest from high-level parameters (Nostr disabled build).
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Why:
|
|
|
|
|
+ /// - Keep request construction consistent even when Nostr is not compiled in.
|
|
|
|
|
+ /// - Still advertise available mints for the unit so payers can route proofs correctly.
|
|
|
|
|
+ /// - Allow callers to attach an HTTP transport when out-of-band delivery is acceptable.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Behavior notes:
|
|
|
|
|
+ /// - Rejects `transport == "nostr"` early so callers can surface a clear UX error.
|
|
|
|
|
+ /// - Encodes P2PK/multisig and HTLC constraints into a NUT-10 secret request for enforceable spending conditions.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// Returns the constructed PaymentRequest and sets `single_use = true` to discourage replay.
|
|
|
|
|
+ #[cfg(not(feature = "nostr"))]
|
|
|
|
|
+ pub async fn create_request(
|
|
|
|
|
+ &self,
|
|
|
|
|
+ params: CreateRequestParams,
|
|
|
|
|
+ ) -> Result<PaymentRequest, Error> {
|
|
|
|
|
+ // Collect available mints for the selected unit
|
|
|
|
|
+ let mints = self
|
|
|
|
|
+ .get_balances(&CurrencyUnit::from_str(¶ms.unit)?)
|
|
|
|
|
+ .await?
|
|
|
|
|
+ .keys()
|
|
|
|
|
+ .cloned()
|
|
|
|
|
+ .collect::<Vec<_>>();
|
|
|
|
|
+
|
|
|
|
|
+ // Transports
|
|
|
|
|
+ let transport_type = params.transport.to_lowercase();
|
|
|
|
|
+ let transports: Vec<Transport> = match transport_type.as_str() {
|
|
|
|
|
+ "nostr" => {
|
|
|
|
|
+ return Err(Error::Custom(
|
|
|
|
|
+ "Nostr is not supported in this build".to_string(),
|
|
|
|
|
+ ))
|
|
|
|
|
+ }
|
|
|
|
|
+ "http" => {
|
|
|
|
|
+ if let Some(url) = ¶ms.http_url {
|
|
|
|
|
+ let http_transport = Transport {
|
|
|
|
|
+ _type: TransportType::HttpPost,
|
|
|
|
|
+ target: url.clone(),
|
|
|
|
|
+ tags: None,
|
|
|
|
|
+ };
|
|
|
|
|
+ vec![http_transport]
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // No URL provided, skip transport
|
|
|
|
|
+ vec![]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ _ => vec![],
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ let nut10 = self
|
|
|
|
|
+ .get_pr_spending_conditions(¶ms)?
|
|
|
|
|
+ .map(Nut10SecretRequest::from);
|
|
|
|
|
+
|
|
|
|
|
+ let req = PaymentRequest {
|
|
|
|
|
+ payment_id: None,
|
|
|
|
|
+ amount: params.amount.map(Amount::from),
|
|
|
|
|
+ unit: Some(CurrencyUnit::from_str(¶ms.unit)?),
|
|
|
|
|
+ single_use: Some(true),
|
|
|
|
|
+ mints: Some(mints),
|
|
|
|
|
+ description: params.description,
|
|
|
|
|
+ transports,
|
|
|
|
|
+ nut10,
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ Ok(req)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Wait for a Nostr payment for the previously constructed PaymentRequest and receive it into the wallet.
|
|
|
|
|
+ #[cfg(all(feature = "nostr", not(target_arch = "wasm32")))]
|
|
|
|
|
+ pub async fn wait_for_nostr_payment(&self, info: NostrWaitInfo) -> Result<Amount> {
|
|
|
|
|
+ use futures::StreamExt;
|
|
|
|
|
+
|
|
|
|
|
+ use crate::wallet::streams::nostr::NostrPaymentEventStream;
|
|
|
|
|
+
|
|
|
|
|
+ let NostrWaitInfo {
|
|
|
|
|
+ keys,
|
|
|
|
|
+ relays,
|
|
|
|
|
+ pubkey,
|
|
|
|
|
+ } = info;
|
|
|
|
|
+
|
|
|
|
|
+ let mut stream = NostrPaymentEventStream::new(keys, relays, pubkey);
|
|
|
|
|
+ let cancel = stream.cancel_token();
|
|
|
|
|
+
|
|
|
|
|
+ // Optional: you may expose cancel to caller, or use a timeout here.
|
|
|
|
|
+ // tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(120)).await; cancel.cancel(); });
|
|
|
|
|
+
|
|
|
|
|
+ while let Some(item) = stream.next().await {
|
|
|
|
|
+ match item {
|
|
|
|
|
+ Ok(payload) => {
|
|
|
|
|
+ let token = crate::nuts::Token::new(
|
|
|
|
|
+ payload.mint,
|
|
|
|
|
+ payload.proofs,
|
|
|
|
|
+ payload.memo,
|
|
|
|
|
+ payload.unit,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ let amount = self
|
|
|
|
|
+ .receive(&token.to_string(), ReceiveOptions::default())
|
|
|
|
|
+ .await?;
|
|
|
|
|
+
|
|
|
|
|
+ // Stop after first successful receipt
|
|
|
|
|
+ cancel.cancel();
|
|
|
|
|
+ return Ok(amount);
|
|
|
|
|
+ }
|
|
|
|
|
+ Err(_) => {
|
|
|
|
|
+ // Keep listening on parse errors; if you prefer fail-fast, return the error
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // If stream ended without receiving a payment, return zero.
|
|
|
|
|
+ Ok(Amount::ZERO)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Wait for a Nostr payment for the previously constructed PaymentRequest and receive it into the wallet.
|
|
|
|
|
+ ///
|
|
|
|
|
+ /// wasm32 fallback: Streams are not available; we await the first matching notification and process it.
|
|
|
|
|
+ #[cfg(all(feature = "nostr", target_arch = "wasm32"))]
|
|
|
|
|
+ pub async fn wait_for_nostr_payment(&self, info: NostrWaitInfo) -> Result<Amount> {
|
|
|
|
|
+ use nostr_sdk::prelude::*;
|
|
|
|
|
+
|
|
|
|
|
+ let NostrWaitInfo {
|
|
|
|
|
+ keys,
|
|
|
|
|
+ relays,
|
|
|
|
|
+ pubkey,
|
|
|
|
|
+ } = info;
|
|
|
|
|
+
|
|
|
|
|
+ let client = nostr_sdk::Client::new(keys);
|
|
|
|
|
+
|
|
|
|
|
+ for r in &relays {
|
|
|
|
|
+ client
|
|
|
|
|
+ .add_read_relay(r.clone())
|
|
|
|
|
+ .await
|
|
|
|
|
+ .map_err(|e| crate::error::Error::Custom(format!("Add relay {r}: {e}")))?;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ client.connect().await;
|
|
|
|
|
+
|
|
|
|
|
+ // Subscribe to events addressed to `pubkey`
|
|
|
|
|
+ let filter = Filter::new().pubkey(pubkey);
|
|
|
|
|
+ client
|
|
|
|
|
+ .subscribe(filter, None)
|
|
|
|
|
+ .await
|
|
|
|
|
+ .map_err(|e| crate::error::Error::Custom(format!("Subscribe: {e}")))?;
|
|
|
|
|
+
|
|
|
|
|
+ // Await notifications until we successfully parse a payment payload and receive it
|
|
|
|
|
+ let mut notifications = client.notifications();
|
|
|
|
|
+ while let Ok(notification) = notifications.recv().await {
|
|
|
|
|
+ if let RelayPoolNotification::Event { event, .. } = notification {
|
|
|
|
|
+ match client.unwrap_gift_wrap(&event).await {
|
|
|
|
|
+ Ok(unwrapped) => {
|
|
|
|
|
+ let rumor = unwrapped.rumor;
|
|
|
|
|
+ match serde_json::from_str::<PaymentRequestPayload>(&rumor.content) {
|
|
|
|
|
+ Ok(payload) => {
|
|
|
|
|
+ let token = crate::nuts::Token::new(
|
|
|
|
|
+ payload.mint,
|
|
|
|
|
+ payload.proofs,
|
|
|
|
|
+ payload.memo,
|
|
|
|
|
+ payload.unit,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ let amount = self
|
|
|
|
|
+ .receive(&token.to_string(), ReceiveOptions::default())
|
|
|
|
|
+ .await?;
|
|
|
|
|
+
|
|
|
|
|
+ return Ok(amount);
|
|
|
|
|
+ }
|
|
|
|
|
+ Err(_) => {
|
|
|
|
|
+ // Ignore malformed payloads and continue listening
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ Err(_) => {
|
|
|
|
|
+ // Ignore unwrap errors and continue listening
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Ok(Amount::ZERO)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|