From 5e2f88bc4f5a02bd357c2530dae4e1010f1bbf46 Mon Sep 17 00:00:00 2001 From: Tony Giorgio Date: Fri, 23 Feb 2024 14:25:01 -0600 Subject: [PATCH] Add well known url routes --- .env.sample | 1 + .gitignore | 1 + Cargo.lock | 1 + Cargo.toml | 1 + flake.nix | 2 ++ src/db.rs | 6 ++++ src/lnurlp.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 21 ++++++++++-- src/nostr.rs | 77 +++++++++++++++++++++++++++++++++++++++++++ src/register.rs | 3 ++ src/routes.rs | 67 +++++++++++++++++++++++++++++++++++++ 11 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 src/lnurlp.rs create mode 100644 src/nostr.rs diff --git a/.env.sample b/.env.sample index 6627a08..5da4116 100644 --- a/.env.sample +++ b/.env.sample @@ -1,4 +1,5 @@ DATABASE_URL=postgres://localhost/hermes FM_DB_PATH=./.fedimint-test-dir NSEC= +DOMAIN_URL= #HERMES_PORT=8080 diff --git a/.gitignore b/.gitignore index 779bb1c..3fe745e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ target/ .direnv .env .fedimint-test-dir +.test # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index 8262b4d..d81e261 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -519,6 +519,7 @@ dependencies = [ "sha2", "tokio", "tower-http", + "url", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7acc73c..a440c03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ fedimint-wallet-client = "0.2.2" fedimint-mint-client = "0.2.2" fedimint-ln-client = "0.2.2" futures = "0.3.28" +url = "2.5.0" itertools = "0.12.0" lightning-invoice = "0.27.0" hex = "0.4.3" diff --git a/flake.nix b/flake.nix index 480db72..5bf33df 100644 --- a/flake.nix +++ b/flake.nix @@ -55,6 +55,8 @@ sed -i 's|DATABASE_URL=postgres://localhost/hermes|DATABASE_URL=postgres://hermes_user:password@localhost:5432/hermes|g' .env # random nsec for CI only sed -i 's|NSEC=|NSEC=nsec1lmtupx60q0pg6lk3kcl0c56mp7xukulmcc2rxu3gd6sage8xzxhs3slpac|g' .env + # localhost domain + sed -i 's|DOMAIN_URL=|DOMAIN_URL=http://127.0.0.1:8080|g' .env fi ''; diff --git a/src/db.rs b/src/db.rs index a56b147..7da152f 100644 --- a/src/db.rs +++ b/src/db.rs @@ -16,6 +16,7 @@ pub(crate) trait DBConnection { fn insert_new_user(&self, name: NewAppUser) -> anyhow::Result; fn get_pending_invoices(&self) -> anyhow::Result>; fn set_invoice_state(&self, invoice: Invoice, s: i32) -> anyhow::Result<()>; + fn get_user_by_name(&self, name: String) -> anyhow::Result>; fn get_user_by_id(&self, id: i32) -> anyhow::Result>; fn get_zap_by_id(&self, id: i32) -> anyhow::Result>; fn set_zap_event_id(&self, zap: Zap, event_id: String) -> anyhow::Result<()>; @@ -41,6 +42,11 @@ impl DBConnection for PostgresConnection { Invoice::get_by_state(conn, 0) } + fn get_user_by_name(&self, name: String) -> anyhow::Result> { + let conn = &mut self.db.get()?; + AppUser::get_by_name(conn, name) + } + fn get_user_by_id(&self, id: i32) -> anyhow::Result> { let conn = &mut self.db.get()?; AppUser::get_by_id(conn, id) diff --git a/src/lnurlp.rs b/src/lnurlp.rs new file mode 100644 index 0000000..70bb8d3 --- /dev/null +++ b/src/lnurlp.rs @@ -0,0 +1,88 @@ +use crate::State; +use anyhow::anyhow; +use fedimint_core::Amount; + +use crate::routes::{LnurlStatus, LnurlType, LnurlWellKnownResponse}; + +pub async fn well_known_lnurlp( + state: &State, + name: String, +) -> anyhow::Result { + let user = state.db.get_user_by_name(name.clone())?; + if user.is_none() { + return Err(anyhow!("NotFound")); + } + + let res = LnurlWellKnownResponse { + callback: format!("{}/lnurlp/{}/callback", state.domain, name).parse()?, + max_sendable: Amount { msats: 100000 }, + min_sendable: Amount { msats: 1000 }, + metadata: "test metadata".to_string(), // TODO what should this be? + comment_allowed: None, + tag: LnurlType::PayRequest, + status: LnurlStatus::Ok, + nostr_pubkey: Some(state.nostr.keys().await.public_key()), + allows_nostr: true, + }; + + Ok(res) +} + +#[cfg(all(test, feature = "integration-tests"))] +mod tests_integration { + use nostr::{key::FromSkStr, Keys}; + use secp256k1::Secp256k1; + use std::sync::Arc; + + use crate::{ + db::setup_db, lnurlp::well_known_lnurlp, mint::MockMultiMintWrapperTrait, + models::app_user::NewAppUser, State, + }; + + #[tokio::test] + pub async fn well_known_nip5_lookup_test() { + dotenv::dotenv().ok(); + let pg_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let db = setup_db(pg_url); + + // swap out fm with a mock here since that's not what is being tested + let mock_mm = MockMultiMintWrapperTrait::new(); + + // nostr + let nostr_nsec_str = std::env::var("NSEC").expect("FM_DB_PATH must be set"); + let nostr_sk = Keys::from_sk_str(&nostr_nsec_str).expect("Invalid NOSTR_SK"); + let nostr = nostr_sdk::Client::new(&nostr_sk); + + let mock_mm = Arc::new(mock_mm); + let state = State { + db: db.clone(), + mm: mock_mm, + secp: Secp256k1::new(), + nostr, + domain: "http://hello.com".to_string(), + }; + + let username = "wellknownuser".to_string(); + let user = NewAppUser { + pubkey: "e6642fd69bd211f93f7f1f36ca51a26a5290eb2dd1b0d8279a87bb0d480c8443".to_string(), + name: username.clone(), + federation_id: "".to_string(), + federation_invite_code: "".to_string(), + }; + + // don't care about error if already exists + let _ = state.db.insert_new_user(user); + + match well_known_lnurlp(&state, username.clone()).await { + Ok(result) => { + assert_eq!( + result.callback, + "http://hello.com/lnurlp/wellknownuser/callback" + .parse() + .unwrap() + ); + } + Err(e) => panic!("shouldn't error: {e}"), + } + } +} diff --git a/src/main.rs b/src/main.rs index 97bb0a5..b2030f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use axum::routing::get; use axum::{extract::DefaultBodyLimit, routing::post}; use axum::{http, Extension, Router, TypedHeader}; use log::{error, info}; -use nostr::{key::FromSkStr, Keys}; +use nostr_sdk::nostr::{key::FromSkStr, Keys}; use secp256k1::{All, Secp256k1}; use std::{path::PathBuf, str::FromStr, sync::Arc}; use tokio::signal::unix::{signal, SignalKind}; @@ -15,13 +15,18 @@ use crate::{ db::{setup_db, DBConnection}, invoice::handle_pending_invoices, mint::{setup_multimint, MultiMintWrapperTrait}, - routes::{check_username, health_check, register_route, valid_origin, validate_cors}, + routes::{ + check_username, health_check, register_route, valid_origin, validate_cors, + well_known_lnurlp_route, well_known_nip5_route, + }, }; mod db; mod invoice; +mod lnurlp; mod mint; mod models; +mod nostr; mod register; mod routes; @@ -51,6 +56,7 @@ pub struct State { mm: Arc, pub secp: Secp256k1, pub nostr: nostr_sdk::Client, + pub domain: String, } #[tokio::main] @@ -83,6 +89,11 @@ async fn main() -> anyhow::Result<()> { nostr.add_relay("wss://relay.damus.io").await?; nostr.connect().await; + // domain + let domain = std::env::var("DOMAIN_URL") + .expect("DATABASE_URL must be set") + .to_string(); + let db = setup_db(pg_url); let secp = Secp256k1::new(); let state = State { @@ -90,6 +101,7 @@ async fn main() -> anyhow::Result<()> { mm, secp, nostr, + domain, }; // spawn a task to check for previous pending invoices @@ -120,6 +132,11 @@ async fn main() -> anyhow::Result<()> { .route("/health-check", get(health_check)) .route("/check-username/:username", get(check_username)) .route("/register", post(register_route)) + .route("/.well-known/nostr.json", get(well_known_nip5_route)) + .route( + "/.well-known/lnurlp/:username", + get(well_known_lnurlp_route), + ) .fallback(fallback) .layer( CorsLayer::new() diff --git a/src/nostr.rs b/src/nostr.rs new file mode 100644 index 0000000..8abb140 --- /dev/null +++ b/src/nostr.rs @@ -0,0 +1,77 @@ +use nostr::prelude::XOnlyPublicKey; +use std::{collections::HashMap, str::FromStr}; + +use crate::State; + +pub fn well_known_nip5( + state: &State, + name: String, +) -> anyhow::Result> { + let user = state.db.get_user_by_name(name)?; + + let mut names = HashMap::new(); + if let Some(user) = user { + names.insert(user.name, XOnlyPublicKey::from_str(&user.pubkey).unwrap()); + } + + Ok(names) +} + +#[cfg(all(test, feature = "integration-tests"))] +mod tests_integration { + use nostr::{key::FromSkStr, Keys}; + use secp256k1::{PublicKey, Secp256k1, XOnlyPublicKey}; + use std::{str::FromStr, sync::Arc}; + + use crate::{ + db::setup_db, mint::MockMultiMintWrapperTrait, models::app_user::NewAppUser, + nostr::well_known_nip5, State, + }; + + #[tokio::test] + pub async fn well_known_nip5_lookup_test() { + dotenv::dotenv().ok(); + let pg_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + let db = setup_db(pg_url); + + // swap out fm with a mock here since that's not what is being tested + let mock_mm = MockMultiMintWrapperTrait::new(); + + // nostr + let nostr_nsec_str = std::env::var("NSEC").expect("FM_DB_PATH must be set"); + let nostr_sk = Keys::from_sk_str(&nostr_nsec_str).expect("Invalid NOSTR_SK"); + let nostr = nostr_sdk::Client::new(&nostr_sk); + + let mock_mm = Arc::new(mock_mm); + let state = State { + db: db.clone(), + mm: mock_mm, + secp: Secp256k1::new(), + nostr, + domain: "http://127.0.0.1:8080".to_string(), + }; + + let username = "wellknownuser".to_string(); + let kpk1 = PublicKey::from_str( + "02e6642fd69bd211f93f7f1f36ca51a26a5290eb2dd1b0d8279a87bb0d480c8443", + ) + .unwrap(); + let pk1 = XOnlyPublicKey::from(kpk1); + let user = NewAppUser { + pubkey: "e6642fd69bd211f93f7f1f36ca51a26a5290eb2dd1b0d8279a87bb0d480c8443".to_string(), + name: username.clone(), + federation_id: "".to_string(), + federation_invite_code: "".to_string(), + }; + + // don't care about error if already exists + let _ = state.db.insert_new_user(user); + + match well_known_nip5(&state, username.clone()) { + Ok(result) => { + assert_eq!(result.get(&username).unwrap().to_string(), pk1.to_string()); + } + Err(e) => panic!("shouldn't error: {e}"), + } + } +} diff --git a/src/register.rs b/src/register.rs index 6b9f095..168b4f4 100644 --- a/src/register.rs +++ b/src/register.rs @@ -137,6 +137,7 @@ mod tests_integration { mm: mock_mm, secp: Secp256k1::new(), nostr, + domain: "http://127.0.0.1:8080".to_string(), }; let name = "veryuniquename123".to_string(); @@ -182,6 +183,7 @@ mod tests_integration { mm: mock_mm, secp: Secp256k1::new(), nostr, + domain: "http://127.0.0.1:8080".to_string(), }; let connect = InviteCode::new( @@ -233,6 +235,7 @@ mod tests_integration { mm: mock_mm, secp: Secp256k1::new(), nostr, + domain: "http://127.0.0.1:8080".to_string(), }; let connect = InviteCode::new( diff --git a/src/routes.rs b/src/routes.rs index f66760b..34e7bf1 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,5 +1,7 @@ use crate::{ + lnurlp::well_known_lnurlp, models::app_user::NewAppUser, + nostr::well_known_nip5, register::{check_available, register}, State, ALLOWED_LOCALHOST, ALLOWED_ORIGINS, ALLOWED_SUBDOMAIN, API_VERSION, }; @@ -9,8 +11,12 @@ use axum::http::StatusCode; use axum::Extension; use axum::{Json, TypedHeader}; use fedimint_core::config::FederationId; +use fedimint_core::Amount; use log::{debug, error}; +use nostr::prelude::XOnlyPublicKey; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use url::Url; pub async fn check_username( origin: Option>, @@ -62,6 +68,67 @@ pub async fn register_route( } } +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct UserWellKnownNip5Req { + pub name: String, +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct UserWellKnownNip5Resp { + pub names: HashMap, +} + +pub async fn well_known_nip5_route( + Extension(state): Extension, + Json(req): Json, +) -> Result, (StatusCode, String)> { + debug!("well_known_route"); + match well_known_nip5(&state, req.name) { + Ok(res) => Ok(Json(UserWellKnownNip5Resp { names: res })), + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LnurlType { + PayRequest, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum LnurlStatus { + Ok, + Error, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LnurlWellKnownResponse { + pub callback: Url, + pub max_sendable: Amount, + pub min_sendable: Amount, + pub metadata: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub comment_allowed: Option, + pub tag: LnurlType, + pub status: LnurlStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub nostr_pubkey: Option, + pub allows_nostr: bool, +} + +pub async fn well_known_lnurlp_route( + Extension(state): Extension, + Path(username): Path, +) -> Result, (StatusCode, String)> { + debug!("well_known_route"); + match well_known_lnurlp(&state, username).await { + Ok(res) => Ok(Json(res)), + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())), + } +} + #[derive(Serialize)] pub struct HealthResponse { pub status: String,