Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions crates/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
resolver = "2"

members = [
"otrta", "otrta-ui"
"otrta", "otrta-nostr", "otrta-ui"
]

[workspace.package]
Expand All @@ -15,6 +15,7 @@ repository = "https://github.com/ecash-402/ecash-402-routstr"
description = "llm ecash 402 payment"

[workspace.dependencies]
nostr = "0.43"

ecash-402-wallet = "0.1.23"
# ecash-402-wallet = {path = "../../ecash-402-wallet/crates/wallet/"}
20 changes: 20 additions & 0 deletions crates/otrta-nostr/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "otrta-nostr"
edition.workspace = true
version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description.workspace = true

[dependencies]
nostr.workspace = true
nostr-sdk = "0.43"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["full"] }
tracing = "0.1"
anyhow = "1.0"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
url = "2.5"
7 changes: 7 additions & 0 deletions crates/otrta-nostr/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
pub mod nip91_discovery;

// Re-export main types for easier access
pub use nip91_discovery::{
Discovery, NostrProvider, Provider, ProviderContent, discover_providers,
get_updated_providers_since,
};
234 changes: 234 additions & 0 deletions crates/otrta-nostr/src/nip91_discovery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
use anyhow::Result;
use chrono::{DateTime, Utc};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
use tracing::{debug, info, warn};

const PROVIDER_ANNOUNCEMENT_KIND: u16 = 38421;

const DEFAULT_RELAYS: &[&str] = &[
"wss://relay.damus.io",
"wss://relay.snort.social",
"wss://nos.lol",
"wss://relay.nostr.band",
"wss://nostr.wine",
"wss://relay.primal.net",
"wss://relay.routstr.com",
];

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderContent {
pub name: String,
pub about: String,
pub urls: Option<Vec<String>>,
pub mints: Option<Vec<String>>,
pub version: Option<String>,
pub use_onion: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NostrProvider {
pub id: String,
pub pubkey: String,
pub name: String,
pub about: String,
pub urls: Vec<String>,
pub mints: Vec<String>,
pub version: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub followers: i32,
pub zaps: i32,
pub use_onion: bool,
}

#[derive(Debug)]
pub struct NostrProviderDiscovery {
relays: Vec<String>,
client: Client,
}

fn parse_tags_to_map(tags: &Tags) -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, Vec<String>> = HashMap::new();

for tag in tags.iter() {
let tag_str = format!("{:?}", tag);
if let Some(start) = tag_str.find('[') {
if let Some(end) = tag_str.find(']') {
let inner = &tag_str[start + 1..end];
let parts: Vec<&str> = inner
.split(',')
.map(|s| s.trim().trim_matches('"'))
.collect();

if parts.len() >= 2 {
let key = parts[0].to_string();
let value = parts[1].to_string();
map.entry(key).or_default().push(value);
}
}
}
}

map
}

impl NostrProviderDiscovery {
pub async fn new() -> Result<Self> {
let relays = DEFAULT_RELAYS.iter().map(|&s| s.to_string()).collect();
let client = Client::default();

for relay in &relays {
if let Err(e) = client.add_relay(relay).await {
warn!("Failed to add relay {}: {}", relay, e);
}
}

Ok(Self { relays, client })
}

pub async fn with_relays(relay_urls: Vec<String>) -> Result<Self> {
let client = Client::default();

for relay in &relay_urls {
if let Err(e) = client.add_relay(relay).await {
warn!("Failed to add relay {}: {}", relay, e);
}
}

Ok(Self {
relays: relay_urls,
client,
})
}

pub async fn discover_providers(&self) -> Result<Vec<NostrProvider>> {
info!("Starting provider discovery from Nostr relays...");

self.client.connect().await;

tokio::time::sleep(Duration::from_secs(2)).await;

let filter = Filter::new()
.kind(Kind::Custom(PROVIDER_ANNOUNCEMENT_KIND))
.limit(100);

info!(
"Fetching provider events from {} relays...",
self.relays.len()
);

let events = self
.client
.fetch_events(filter, Duration::from_secs(10))
.await
.unwrap();

println!("Retrieved {} provider events", events.len());

let mut providers = Vec::new();

for event in events {
match self.parse_provider_from_event(&event) {
Ok(provider) => {
debug!("Successfully parsed provider: {}", provider.name);
providers.push(provider);
}
Err(e) => {
println!("Failed to parse provider event {}: {}", event.id, e);
continue;
}
}
}

if providers.is_empty() {}

info!("Discovered {} providers", providers.len());
Ok(providers)
}

fn parse_provider_from_event(&self, event: &Event) -> Result<NostrProvider> {
println!("Parsing event content: {}", event.content);
println!("Event tags: {:?}", event.tags);

let content: ProviderContent = match serde_json::from_str(&event.content) {
Ok(content) => content,
Err(e) => {
return Err(anyhow::anyhow!("Failed to parse JSON content: {}", e));
}
};

let mut urls = content.urls.unwrap_or_default();
let mut mints = content.mints.unwrap_or_default();
let mut version = content.version;
let mut use_onion = content.use_onion.unwrap_or(false);

let tag_map = parse_tags_to_map(&event.tags);
println!("Parsed tag map: {:?}", tag_map);

if let Some(tag_urls) = tag_map.get("u") {
for url in tag_urls {
urls.push(url.clone());
if url.contains(".onion") {
use_onion = true;
}
}
}

if let Some(tag_mints) = tag_map.get("mint") {
for mint in tag_mints {
mints.push(mint.clone());
}
}

if let Some(tag_version) = tag_map.get("version") {
if let Some(v) = tag_version.first() {
version = Some(v.clone());
}
}

if urls.is_empty() {
return Err(anyhow::anyhow!("Provider must have at least one URL"));
}

if !use_onion {
use_onion =
urls.iter().any(|url| url.contains(".onion")) || content.use_onion.unwrap_or(false);
}

Ok(NostrProvider {
id: event.id.to_hex(),
pubkey: event.pubkey.to_hex(),
name: content.name,
about: content.about,
urls,
mints,
version,
created_at: DateTime::from_timestamp(event.created_at.as_u64() as i64, 0)
.unwrap_or_else(|| Utc::now()),
updated_at: Utc::now(),
followers: 0,
zaps: 0,
use_onion,
})
}

pub async fn get_updated_providers(&self, _since: DateTime<Utc>) -> Result<Vec<NostrProvider>> {
self.discover_providers().await
}
}

pub async fn discover_providers() -> Result<Vec<NostrProvider>> {
let discovery = NostrProviderDiscovery::new().await?;
discovery.discover_providers().await
}

pub async fn get_updated_providers_since(since: DateTime<Utc>) -> Result<Vec<NostrProvider>> {
let discovery = NostrProviderDiscovery::new().await?;
discovery.get_updated_providers(since).await
}

pub use NostrProvider as Provider;
pub use NostrProviderDiscovery as Discovery;
1 change: 1 addition & 0 deletions crates/otrta-ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ secrecy = {version = "0.10", features = ["serde"]}
ecash-402-wallet.workspace = true

otrta = { path = "../otrta"}
otrta-nostr = { path = "../otrta-nostr" }
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Add up migration script here

ALTER TABLE providers
ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'manual' NOT NULL;

UPDATE providers SET source = 'manual' WHERE source IS NULL;

CREATE INDEX IF NOT EXISTS idx_providers_source ON providers(source);
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Revert provider URL constraint back to simple URL uniqueness
-- This removes the source-aware constraint and restores the original behavior

DO $$
BEGIN
-- Drop the composite unique constraint if it exists
IF EXISTS (
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'providers'
AND constraint_name = 'providers_url_source_key'
AND constraint_type = 'UNIQUE'
) THEN
ALTER TABLE providers DROP CONSTRAINT providers_url_source_key;
END IF;

-- Add back the simple unique constraint on url
-- Note: This may fail if there are duplicate URLs with different sources
ALTER TABLE providers ADD CONSTRAINT providers_url_key UNIQUE (url);

END $$;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- Update provider URL constraint to allow same URL for different sources
-- This enables having the same URL for both 'nostr' and 'manual' providers

DO $$
BEGIN
-- Drop the existing unique constraint on url if it exists
IF EXISTS (
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = 'providers'
AND constraint_name = 'providers_url_key'
AND constraint_type = 'UNIQUE'
) THEN
ALTER TABLE providers DROP CONSTRAINT providers_url_key;
END IF;

-- Drop any existing composite index
DROP INDEX IF EXISTS providers_url_org_unique;

-- Add new unique constraint on url + source combination
-- This allows same URL for different sources (nostr vs manual)
ALTER TABLE providers ADD CONSTRAINT providers_url_source_key UNIQUE (url, source);

END $$;
Loading