diff --git a/Cargo.lock b/Cargo.lock index 4a17609..45c43f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1004,8 +1004,8 @@ dependencies = [ "jsonrpsee-types", "jsonrpsee-wasm-client", "jsonrpsee-ws-client", - "lightning", - "lightning-invoice", + "lightning 0.0.118", + "lightning-invoice 0.26.0", "macro_rules_attribute", "miniscript", "parity-scale-codec", @@ -1075,7 +1075,7 @@ dependencies = [ "fedimint-threshold-crypto", "futures", "itertools 0.10.5", - "lightning-invoice", + "lightning-invoice 0.26.0", "rand", "reqwest", "secp256k1 0.24.3", @@ -1106,8 +1106,8 @@ dependencies = [ "fedimint-threshold-crypto", "futures", "itertools 0.10.5", - "lightning", - "lightning-invoice", + "lightning 0.0.118", + "lightning-invoice 0.26.0", "rand", "secp256k1 0.24.3", "serde", @@ -1653,8 +1653,10 @@ dependencies = [ "fedimint-rocksdb", "fedimint-wallet-client", "futures", + "hex", "itertools 0.12.0", "lazy_static", + "lightning-invoice 0.27.0", "nostr", "nostr-sdk", "serde", @@ -1683,6 +1685,12 @@ dependencies = [ "serde", ] +[[package]] +name = "hex-conservative" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ed443af458ccb6d81c1e7e661545f94d3176752fb1df2f543b902a1e0f51e2" + [[package]] name = "hex_fmt" version = "0.3.0" @@ -2283,6 +2291,16 @@ dependencies = [ "hashbrown 0.8.2", ] +[[package]] +name = "lightning" +version = "0.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32899363dc48d297838ce1264c4bb6867ede6ec11f06f3b9823f3ff403f1ccc2" +dependencies = [ + "bitcoin 0.30.2", + "hex-conservative", +] + [[package]] name = "lightning-invoice" version = "0.26.0" @@ -2292,12 +2310,25 @@ dependencies = [ "bech32", "bitcoin 0.29.2", "bitcoin_hashes 0.11.0", - "lightning", + "lightning 0.0.118", "num-traits", "secp256k1 0.24.3", "serde", ] +[[package]] +name = "lightning-invoice" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3421ccfaeac77763114f68f3fc5308f11a0e8a880618b4f84d1d22493aa465" +dependencies = [ + "bech32", + "bitcoin 0.30.2", + "lightning 0.0.119", + "num-traits", + "secp256k1 0.27.0", +] + [[package]] name = "linked-hash-map" version = "0.5.6" diff --git a/Cargo.toml b/Cargo.toml index 338540c..ea10f82 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ serde_json = "1.0.108" tokio = { version = "1.34.0", features = ["full"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" +lightning-invoice = "0.27.0" fedimint-client = { git = "https://github.com/fedimint/fedimint", branch = "releases/v0.2" } fedimint-core = { git = "https://github.com/fedimint/fedimint", branch = "releases/v0.2" } fedimint-wallet-client = { git = "https://github.com/fedimint/fedimint", branch = "releases/v0.2" } @@ -37,3 +38,4 @@ sqlb = "0.4.0" futures = "0.3.30" xmpp = "0.5.0" itertools = "0.12.0" +hex = "0.4.3" diff --git a/migrations/20231224194609_create_tables.sql b/migrations/20231224194609_create_tables.sql index c2716f8..74d40e8 100644 --- a/migrations/20231224194609_create_tables.sql +++ b/migrations/20231224194609_create_tables.sql @@ -22,3 +22,9 @@ CREATE TABLE invoice ( amount BIGINT NOT NULL, state INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE zaps +( + id INTEGER NOT NULL PRIMARY KEY references invoice (id), + request TEXT NOT NULL, + event_id VARCHAR(64) +); \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index cbfa5ce..a9a3b07 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,8 @@ use fedimint_client::derivable_secret::DerivableSecret; use fedimint_client::secret::{PlainRootSecretStrategy, RootSecretStrategy}; use fedimint_core::api::InviteCode; use nostr::hashes::hex::FromHex; +use nostr::key::FromSkStr; +use nostr::Keys; use std::env; use std::path::PathBuf; use std::str::FromStr; @@ -19,7 +21,7 @@ pub struct Config { pub root_secret: DerivableSecret, pub fm_db_path: PathBuf, pub pg_db: String, - pub nostr_sk: String, + pub nostr_sk: Keys, pub default_relay: String, pub xmpp_username: String, pub xmpp_password: String, @@ -48,6 +50,7 @@ impl Config { let pg_db = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let nostr_sk = env::var("NOSTR_SK").expect("NOSTR_SK must be set"); + let nostr_sk = Keys::from_sk_str(&nostr_sk).expect("Invalid NOSTR_SK"); let default_relay = env::var("DEFAULT_NOSTR_RELAY").expect("DEFAULT_NOSTR_RELAY must be set"); diff --git a/src/model/mod.rs b/src/model/mod.rs index 34fd5a4..709644e 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -5,6 +5,7 @@ pub mod invoice; pub mod invoice_state; pub mod relay; mod store; +pub mod zap; use crate::model::store::{new_db_pool, Db}; use anyhow::Result; diff --git a/src/model/zap.rs b/src/model/zap.rs new file mode 100644 index 0000000..90e61ef --- /dev/null +++ b/src/model/zap.rs @@ -0,0 +1,51 @@ +#![allow(dead_code)] + +use super::{ + base::{self, DbBmc}, + ModelManager, +}; +use anyhow::Result; +use nostr::EventId; +use serde::Serialize; +use sqlb::Fields; +use sqlx::FromRow; + +#[derive(Debug, Clone, Fields, FromRow, Serialize)] +pub struct Zap { + pub id: i32, + pub request: String, + pub event_id: Option, +} + +#[derive(Debug, Clone, Fields, FromRow, Serialize)] +pub struct ZapForUpdate { + pub event_id: String, +} + +pub struct ZapBmc; + +impl DbBmc for ZapBmc { + const TABLE: &'static str = "zaps"; +} + +impl ZapBmc { + pub async fn create(mm: &ModelManager, inv_c: Zap) -> Result { + base::create::(mm, inv_c).await + } + + pub async fn get(mm: &ModelManager, id: i32) -> Result { + base::get::(mm, id).await + } + + pub async fn set_event_id(mm: &ModelManager, id: i32, event_id: EventId) -> Result<()> { + let u = ZapForUpdate { + event_id: event_id.to_hex(), + }; + base::update::(mm, id, u).await?; + Ok(()) + } + + pub async fn delete(mm: &ModelManager, id: i32) -> Result<()> { + base::delete::(mm, id).await + } +} diff --git a/src/router/handlers/lnurlp/callback.rs b/src/router/handlers/lnurlp/callback.rs index fd85f32..28f9547 100644 --- a/src/router/handlers/lnurlp/callback.rs +++ b/src/router/handlers/lnurlp/callback.rs @@ -11,7 +11,14 @@ use fedimint_core::{core::OperationId, task::spawn, Amount}; use fedimint_ln_client::{LightningClientModule, LnReceiveState}; use fedimint_mint_client::{MintClientModule, OOBNotes}; use futures::StreamExt; +use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret}; +use nostr::bitcoin::hashes::sha256::Hash as Sha256; +use nostr::hashes::Hash; +use nostr::key::{Secp256k1, SecretKey}; +use nostr::prelude::rand::rngs::OsRng; +use nostr::prelude::rand::RngCore; use nostr::secp256k1::XOnlyPublicKey; +use nostr::{Event, EventBuilder, JsonUtil, Kind}; use serde::{Deserialize, Serialize}; use serde_json::json; use tracing::{error, info}; @@ -19,6 +26,7 @@ use url::Url; use xmpp::{parsers::message::MessageType, Jid}; use crate::model::invoice_state::InvoiceState; +use crate::model::zap::{Zap, ZapBmc}; use crate::{ config::CONFIG, error::AppError, @@ -43,6 +51,8 @@ pub struct LnurlCallbackParams { pub comment: Option, // Optional parameter to pass the LN WALLET user's comment to LN SERVICE #[serde(default, deserialize_with = "empty_string_as_none")] pub proofofpayer: Option, // Optional ephemeral secp256k1 public key generated by payer + #[serde(default, deserialize_with = "empty_string_as_none")] + pub nostr: Option, // Optional zap request } #[derive(Serialize, Deserialize)] @@ -81,6 +91,19 @@ pub async fn handle_callback( status: StatusCode::BAD_REQUEST, }); } + + // verify nostr param is a zap request + if params + .nostr + .as_ref() + .is_some_and(|n| Event::from_json(n).is_ok_and(|e| e.kind == Kind::ZapRequest)) + { + return Err(AppError { + error: anyhow::anyhow!("Invalid nostr event"), + status: StatusCode::BAD_REQUEST, + }); + } + let nip05relays = AppUserRelaysBmc::get_by(&state.mm, NameOrPubkey::Name, &username).await?; let ln = state.fm.get_first_module::(); @@ -89,7 +112,7 @@ pub async fn handle_callback( Amount { msats: params.amount, }, - "test invoice".to_string(), + "test invoice".to_string(), // todo set description hash properly None, (), ) @@ -107,6 +130,19 @@ pub async fn handle_callback( ) .await?; + // save nostr zap request + if let Some(request) = params.nostr { + ZapBmc::create( + &state.mm, + Zap { + id, + request, + event_id: None, + }, + ) + .await?; + } + // create subscription to operation let subscription = ln .subscribe_ln_receive(op_id) @@ -154,7 +190,7 @@ pub(crate) async fn spawn_invoice_subscription( let invoice = InvoiceBmc::set_state(&state.mm, id, InvoiceState::Settled) .await .expect("settling invoice can't fail"); - notify_user(&state, invoice.amount as u64, userrelays.clone()) + notify_user(&state, id, invoice.amount as u64, userrelays.clone()) .await .expect("notifying user can't fail"); break; @@ -167,6 +203,7 @@ pub(crate) async fn spawn_invoice_subscription( async fn notify_user( state: &AppState, + id: i32, amount: u64, app_user_relays: AppUserRelays, ) -> Result<(), Box> { @@ -179,6 +216,18 @@ async fn notify_user( "xmpp" => send_xmpp_msg(&app_user_relays, operation_id, amount, notes).await, _ => Err(anyhow::anyhow!("Unsupported dm_type")), }?; + + // Send zap if needed + if let Ok(zap) = ZapBmc::get(&state.mm, id).await { + let request = Event::from_json(zap.request)?; + let event = create_zap_event(request, amount)?; + + let event_id = state.nostr.send_event(event).await?; + info!("Broadcasted zap {event_id}!"); + + ZapBmc::set_event_id(&state.mm, id, event_id).await?; + } + Ok(()) } @@ -236,3 +285,37 @@ async fn send_xmpp_msg( Ok(()) } + +/// Creates a nostr zap event with a fake invoice +fn create_zap_event(request: Event, amt_msats: u64) -> Result { + let preimage = &mut [0u8; 32]; + OsRng.fill_bytes(preimage); + let invoice_hash = Sha256::hash(preimage); + + let payment_secret = &mut [0u8; 32]; + OsRng.fill_bytes(payment_secret); + + let priv_key_bytes = &mut [0u8; 32]; + OsRng.fill_bytes(priv_key_bytes); + let private_key = SecretKey::from_slice(priv_key_bytes)?; + + let desc_hash = Sha256::hash(request.as_json().as_bytes()); + + let fake_invoice = InvoiceBuilder::new(Currency::Bitcoin) + .amount_milli_satoshis(amt_msats) + .description_hash(desc_hash) + .current_timestamp() + .payment_hash(invoice_hash) + .payment_secret(PaymentSecret(*payment_secret)) + .min_final_cltv_expiry_delta(144) + .build_signed(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &private_key))?; + + let event = EventBuilder::new_zap_receipt( + fake_invoice.to_string(), + Some(hex::encode(preimage)), + request, + ) + .to_event(&CONFIG.nostr_sk)?; + + Ok(event) +} diff --git a/src/router/handlers/lnurlp/well_known.rs b/src/router/handlers/lnurlp/well_known.rs index 6460fcb..4da1ead 100644 --- a/src/router/handlers/lnurlp/well_known.rs +++ b/src/router/handlers/lnurlp/well_known.rs @@ -1,5 +1,3 @@ -use std::str::FromStr; - use super::{LnurlStatus, LnurlType}; use crate::config::CONFIG; use crate::error::AppError; @@ -65,7 +63,7 @@ pub async fn handle_well_known( ) -> Result, AppError> { // see if username exists in nostr.json info!("well_known called with username: {}", username); - let app_user = AppUserBmc::get_by(&state.mm, NameOrPubkey::Name, &username).await?; + let _app_user = AppUserBmc::get_by(&state.mm, NameOrPubkey::Name, &username).await?; let res = LnurlWellKnownResponse { callback: format!("http://{}/lnurlp/{}/callback", CONFIG.domain, username).parse()?, @@ -75,7 +73,7 @@ pub async fn handle_well_known( comment_allowed: None, tag: LnurlType::PayRequest, status: LnurlStatus::Ok, - nostr_pubkey: Some(XOnlyPublicKey::from_str(&app_user.pubkey)?), + nostr_pubkey: Some(CONFIG.nostr_sk.public_key()), allows_nostr: true, }; diff --git a/src/state.rs b/src/state.rs index 3ac9614..4e8c55c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,6 +1,4 @@ use fedimint_client::ClientArc; -use nostr::key::FromSkStr; -use nostr::Keys; use nostr_sdk::Client; use crate::{config, model::ModelManager}; @@ -41,8 +39,7 @@ pub async fn load_fedimint_client() -> Result { } pub async fn load_nostr_client() -> Result { - let keys = Keys::from_sk_str(&CONFIG.nostr_sk)?; - let client = nostr_sdk::Client::new(&keys); + let client = nostr_sdk::Client::new(&CONFIG.nostr_sk); client.add_relay(CONFIG.default_relay.as_str()).await?; client.connect().await;