Add live e2e tests for the nostr relay and NIP-05 server
tests/nostr_e2e.rs (run with --ignored): - nip17_slatepack_roundtrip: Alice gift-wraps a slatepack payment DM with a subject to Bob over wss://nrelay.us-ea.st; Bob unwraps, the seal-author == sender invariant holds, the slatepack and subject extract intact. - nip05_registration_roundtrip: registers a fresh name on goblin.st with a real NIP-98 signature, resolves it back to the right pubkey, releases it. Both pass against the live deployed infrastructure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<SendFlow>,
|
||||
/// 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
let doc: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
doc.get("names")?.get(name)?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
Reference in New Issue
Block a user