diff --git a/Cargo.toml b/Cargo.toml index c867550..16828b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,3 +150,10 @@ eframe = { version = "0.33.2", default-features = false, features = ["glow", "an [build-dependencies] built = "0.8.0" + +[dev-dependencies] +nostr-sdk = { version = "0.44", features = ["nip06", "nip44", "nip49", "nip59", "nip98"] } +tokio = { version = "1.49.0", features = ["full"] } +base64 = "0.22" +sha2 = "0.10" +hex = "0.4" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 2780543..421d2be 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -24,7 +24,6 @@ use egui::{Align, Layout, Margin, RichText, ScrollArea, Sense, Vec2}; use crate::gui::icons::{CLOCK, COPY, SCAN, USER_CIRCLE, WALLET}; use crate::gui::platform::PlatformCallbacks; use crate::gui::theme::{self, fonts}; -use crate::gui::views::wallets::wallet::WalletTransactionsContent; use crate::gui::views::{Content, View}; use crate::wallet::Wallet; use crate::wallet::types::WalletData; @@ -46,8 +45,6 @@ pub enum Tab { pub struct GoblinWalletView { tab: Tab, send: Option, - /// Legacy transactions content reused for detail rendering when needed. - txs_content: WalletTransactionsContent, } impl Default for GoblinWalletView { @@ -55,7 +52,6 @@ impl Default for GoblinWalletView { Self { tab: Tab::Home, send: None, - txs_content: WalletTransactionsContent::new(None), } } } diff --git a/src/nostr/mod.rs b/src/nostr/mod.rs index 8344089..950f5d7 100644 --- a/src/nostr/mod.rs +++ b/src/nostr/mod.rs @@ -30,10 +30,10 @@ pub use store::NostrStore; mod identity; pub use identity::{IdentitySource, NostrIdentity}; -mod protocol; +pub mod protocol; pub use protocol::*; -mod ingest; +pub mod ingest; pub use ingest::*; mod client; diff --git a/tests/nostr_e2e.rs b/tests/nostr_e2e.rs new file mode 100644 index 0000000..1e4c049 --- /dev/null +++ b/tests/nostr_e2e.rs @@ -0,0 +1,199 @@ +// End-to-end Nostr exchange test against the live Goblin relay. +// +// Proves the NIP-17 payment-message path: gift-wrap send, subscribe, unwrap, +// seal-author verification, subject tag, and Goblin slatepack extraction. +// Network-dependent — run explicitly: +// cargo test --test nostr_e2e -- --ignored --nocapture + +use std::time::Duration; + +use grim::nostr::protocol; +use nostr_sdk::prelude::*; + +const RELAY: &str = "wss://nrelay.us-ea.st"; + +/// A small but valid-looking slatepack armor block for extraction testing. +const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \ + pcXQhyRkHbyKHZg GN75o7uWoT3dkib R2tj1fFGN2FoRLY oeBPyKizupksgRT \ + dXFdjEuMUuktR5r gCiVBSXcHSWW3KW Y56LTQ9z3QwUWmE 8sRtwR9Bn8oNN5K. \ + ENDSLATEPACK."; + +#[tokio::test] +#[ignore] +async fn nip17_slatepack_roundtrip() { + let alice = Keys::generate(); + let bob = Keys::generate(); + println!("alice: {}", alice.public_key().to_bech32().unwrap()); + println!("bob: {}", bob.public_key().to_bech32().unwrap()); + + // Bob's client: connect, advertise DM relays, subscribe to gift wraps. + let bob_client = Client::new(bob.clone()); + bob_client.add_relay(RELAY).await.unwrap(); + bob_client.connect().await; + tokio::time::sleep(Duration::from_secs(2)).await; + + // Publish Bob's kind-10050 DM relay list so senders find this relay. + let dm_relays = EventBuilder::new(Kind::InboxRelays, "") + .tag(Tag::custom(TagKind::custom("relay"), [RELAY.to_string()])); + bob_client.send_event_builder(dm_relays).await.unwrap(); + + let filter = Filter::new() + .kind(Kind::GiftWrap) + .pubkey(bob.public_key()) + .since(Timestamp::now() - Duration::from_secs(3 * 86_400)); + bob_client.subscribe(filter, None).await.unwrap(); + + // Alice's client: connect and send a NIP-17 payment DM to Bob. + let alice_client = Client::new(alice.clone()); + alice_client.add_relay(RELAY).await.unwrap(); + alice_client.connect().await; + tokio::time::sleep(Duration::from_secs(2)).await; + + let content = protocol::build_payment_content(SLATEPACK); + let tags = protocol::build_rumor_tags(Some("lunch :)")); + alice_client + .send_private_msg_to([RELAY], bob.public_key(), content, tags) + .await + .unwrap(); + println!("alice sent gift-wrapped payment DM"); + + // Bob waits for the gift wrap, unwraps and validates it. + let mut notifications = bob_client.notifications(); + let result = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Ok(RelayPoolNotification::Event { event, .. }) = notifications.recv().await { + if event.kind != Kind::GiftWrap { + continue; + } + let unwrapped = match bob_client.unwrap_gift_wrap(&event).await { + Ok(u) => u, + Err(_) => continue, + }; + // Seal-author check (the NIP-17 invariant our ingest enforces). + assert_eq!( + unwrapped.rumor.pubkey, unwrapped.sender, + "rumor author must equal seal signer" + ); + assert_eq!(unwrapped.sender, alice.public_key(), "sender must be Alice"); + assert_eq!(unwrapped.rumor.kind, Kind::PrivateDirectMessage); + return unwrapped; + } + } + }) + .await + .expect("timed out waiting for gift wrap"); + + // The slatepack must round-trip intact, and the subject must survive. + let armor = protocol::extract_slatepack(&result.rumor.content) + .expect("slatepack must extract from rumor"); + assert!(armor.starts_with("BEGINSLATEPACK.")); + assert!(armor.ends_with("ENDSLATEPACK.")); + let subject = protocol::extract_subject(&result.rumor.tags); + assert_eq!(subject.as_deref(), Some("lunch :)")); + + println!("✓ NIP-17 slatepack roundtrip verified over {RELAY}"); + bob_client.disconnect().await; + alice_client.disconnect().await; +} + +/// Register a fresh name on goblin.st with a real NIP-98 signature, then +/// resolve it back — proves the live identity server end-to-end. +/// Run: cargo test --test nostr_e2e nip05 -- --ignored --nocapture +#[tokio::test] +#[ignore] +async fn nip05_registration_roundtrip() { + use base64::Engine; + use sha2::{Digest, Sha256}; + use std::process::Command; + + let keys = Keys::generate(); + let pubkey = keys.public_key().to_hex(); + // Unique-ish name from the pubkey suffix (lowercase alnum). + let name = format!("t{}", &pubkey[..8]); + let server = "https://goblin.st"; + let url = format!("{server}/api/v1/register"); + let body = serde_json::json!({ "name": name, "pubkey": pubkey }).to_string(); + + // Build the NIP-98 kind-27235 auth event (same shape as the client). + let payload_hash = hex::encode(Sha256::digest(body.as_bytes())); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tag(Tag::custom(TagKind::custom("u"), [url.clone()])) + .tag(Tag::custom(TagKind::custom("method"), ["POST".to_string()])) + .tag(Tag::custom(TagKind::custom("payload"), [payload_hash])) + .sign_with_keys(&keys) + .unwrap(); + let auth = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(event.as_json()) + ); + + // POST the registration via curl (avoids pulling an HTTP client dep). + let out = Command::new("curl") + .args([ + "-s", + "-X", + "POST", + &url, + "-H", + &format!("Authorization: {auth}"), + "-H", + "Content-Type: application/json", + "-d", + &body, + ]) + .output() + .expect("curl register"); + let resp = String::from_utf8_lossy(&out.stdout); + println!("register response: {resp}"); + assert!( + resp.contains("\"nip05\""), + "registration should return a nip05 identifier, got: {resp}" + ); + assert!(resp.contains(&format!("{name}@goblin.st"))); + + // Resolve it back from the well-known endpoint. + let wk = Command::new("curl") + .args([ + "-s", + &format!("{server}/.well-known/nostr.json?name={name}"), + ]) + .output() + .expect("curl well-known"); + let wk_body = String::from_utf8_lossy(&wk.stdout); + println!("well-known response: {wk_body}"); + let resolved = protocol_parse_pubkey(&wk_body, &name); + assert_eq!(resolved.as_deref(), Some(pubkey.as_str())); + + // Clean up: release the test name. + let del_url = format!("{server}/api/v1/register/{name}"); + let del_event = EventBuilder::new(Kind::HttpAuth, "") + .tag(Tag::custom(TagKind::custom("u"), [del_url.clone()])) + .tag(Tag::custom( + TagKind::custom("method"), + ["DELETE".to_string()], + )) + .sign_with_keys(&keys) + .unwrap(); + let del_auth = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(del_event.as_json()) + ); + let _ = Command::new("curl") + .args([ + "-s", + "-X", + "DELETE", + &del_url, + "-H", + &format!("Authorization: {del_auth}"), + ]) + .output(); + + println!("✓ NIP-05 registration + resolution verified on {server}"); +} + +/// Minimal well-known pubkey extractor for the test. +fn protocol_parse_pubkey(body: &str, name: &str) -> Option { + let doc: serde_json::Value = serde_json::from_str(body).ok()?; + doc.get("names")?.get(name)?.as_str().map(|s| s.to_string()) +}