commit a7da4d6a11915f3cb788027b81f4ed495bd39596
parent 38fb05475da068ab12775993b9261a6bd5409dea
Author: kernelkind <kernelkind@gmail.com>
Date: Tue, 1 Apr 2025 17:29:21 -0400
add `Zap`
Signed-off-by: kernelkind <kernelkind@gmail.com>
Diffstat:
5 files changed, 334 insertions(+), 4 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -575,7 +575,7 @@ dependencies = [
"bitcoin_hashes 0.14.0",
"hex-conservative 0.2.1",
"hex_lit",
- "secp256k1",
+ "secp256k1 0.29.1",
"serde",
]
@@ -2450,6 +2450,26 @@ dependencies = [
]
[[package]]
+name = "lightning-invoice"
+version = "0.33.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4254e7d05961a3728bc90737c522e7091735ba6f2f71014096d4b3eb4ee5d89"
+dependencies = [
+ "bech32",
+ "bitcoin",
+ "lightning-types",
+]
+
+[[package]]
+name = "lightning-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2cd84d4e71472035903e43caded8ecc123066ce466329ccd5ae537a8d5488c7"
+dependencies = [
+ "bitcoin",
+]
+
+[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2794,7 +2814,7 @@ dependencies = [
"getrandom 0.2.15",
"instant",
"scrypt",
- "secp256k1",
+ "secp256k1 0.29.1",
"serde",
"serde_json",
"unicode-normalization",
@@ -2859,6 +2879,7 @@ dependencies = [
"hex",
"image",
"jni",
+ "lightning-invoice",
"mime_guess",
"nostr 0.37.0",
"nostrdb",
@@ -2867,6 +2888,7 @@ dependencies = [
"profiling",
"puffin",
"puffin_egui",
+ "secp256k1 0.30.0",
"serde",
"serde_json",
"sha2",
@@ -4180,6 +4202,17 @@ dependencies = [
]
[[package]]
+name = "secp256k1"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252"
+dependencies = [
+ "bitcoin_hashes 0.14.0",
+ "rand 0.8.5",
+ "secp256k1-sys",
+]
+
+[[package]]
name = "secp256k1-sys"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -64,6 +64,8 @@ mime_guess = "2.0.5"
pretty_assertions = "1.4.1"
jni = "0.21.1"
profiling = "1.0"
+lightning-invoice = "0.33.1"
+secp256k1 = "0.30.0"
[profile.small]
inherits = 'release'
diff --git a/crates/notedeck/Cargo.toml b/crates/notedeck/Cargo.toml
@@ -36,6 +36,8 @@ profiling = { workspace = true }
nwc = { workspace = true }
tokio = { workspace = true }
bech32 = { workspace = true }
+lightning-invoice = { workspace = true }
+secp256k1 = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
diff --git a/crates/notedeck/src/zaps/mod.rs b/crates/notedeck/src/zaps/mod.rs
@@ -1 +1,2 @@
-mod networking;
-\ No newline at end of file
+mod networking;
+mod zap;
+\ No newline at end of file
diff --git a/crates/notedeck/src/zaps/zap.rs b/crates/notedeck/src/zaps/zap.rs
@@ -0,0 +1,292 @@
+use enostr::{NoteId, Pubkey};
+use image::EncodableLayout;
+use lightning_invoice::Bolt11Invoice;
+use secp256k1::{schnorr::Signature, Message, Secp256k1, XOnlyPublicKey};
+use sha2::Digest;
+
+#[allow(dead_code)]
+#[derive(Debug)]
+pub enum ZapTarget {
+ Profile(Pubkey),
+ Note(NoteZapTarget),
+}
+
+#[allow(dead_code)]
+#[derive(Debug)]
+pub struct NoteZapTarget {
+ pub note_id: NoteId,
+ pub author: Pubkey,
+}
+
+#[allow(dead_code)]
+#[derive(Debug)]
+pub struct Zap {
+ pub sender: Pubkey,
+ pub target: ZapTarget,
+ pub invoice: Bolt11Invoice,
+}
+
+#[allow(dead_code)]
+impl Zap {
+ pub fn from_zap_event(zap_event: nostrdb::Note, sender: &Pubkey) -> Option<Self> {
+ if sender.bytes() != zap_event.pubkey() {
+ // Make sure that we only create a zap event if it is authorized by the profile or event
+ return None;
+ }
+
+ let zap_tags = get_zap_tags(zap_event)?;
+ let invoice = zap_tags.bolt11.parse::<Bolt11Invoice>().ok()?;
+
+ // invoice must be specific
+ invoice.amount_milli_satoshis()?;
+
+ if let Some(preimage) = zap_tags.preimage {
+ if !preimage_matches_invoice(&invoice, preimage) {
+ return None;
+ }
+ }
+
+ let Ok(zap_req) = enostr::Note::from_json(zap_tags.description) else {
+ return None;
+ };
+
+ if !valid_zap_request(zap_req) {
+ return None;
+ }
+
+ let zap_target = determine_zap_target(&zap_tags)?;
+
+ Some(Zap {
+ sender: *sender,
+ target: zap_target,
+ invoice,
+ })
+ }
+}
+
+#[allow(dead_code)]
+pub fn event_tag<'a>(ev: nostrdb::Note<'a>, name: &str) -> Option<&'a str> {
+ ev.tags().iter().find_map(|tag| {
+ if tag.count() < 2 {
+ return None;
+ }
+
+ let cur_name = tag.get_str(0)?;
+
+ if cur_name != name {
+ return None;
+ }
+
+ tag.get_str(1)
+ })
+}
+
+fn determine_zap_target(tags: &ZapTags) -> Option<ZapTarget> {
+ if let Some(note_zapped) = tags.note_zapped {
+ Some(ZapTarget::Note(NoteZapTarget {
+ note_id: NoteId::new(*note_zapped),
+ author: Pubkey::new(*tags.recipient),
+ }))
+ } else {
+ Some(ZapTarget::Profile(Pubkey::new(*tags.recipient)))
+ }
+}
+
+pub fn event_commitment(
+ pubkey: Pubkey,
+ created_at: u64,
+ kind: u64,
+ tags: Vec<Vec<String>>,
+ content: String,
+) -> String {
+ // Serialize the content and tags into JSON strings.
+ let content_json = serde_json::to_string(&content).expect("Failed to serialize content");
+ let tags_json = serde_json::to_string(&tags).expect("Failed to serialize tags");
+
+ format!(
+ "[0,\"{}\",{},{},{},{}]",
+ pubkey.hex(),
+ created_at,
+ kind,
+ tags_json,
+ content_json
+ )
+}
+
+// TODO(kernelkind): i think we may be able to validate just with the nostrdb::Note. Not exactly sure yet how though
+fn valid_zap_request(note: enostr::Note) -> bool {
+ let sig = note.sig.clone();
+
+ let commitment = event_commitment(
+ note.pubkey,
+ note.created_at,
+ note.kind,
+ note.tags,
+ note.content,
+ );
+
+ let commitment_bytes = commitment.as_bytes();
+ let hash = sha256(commitment_bytes);
+ let check_noteid = NoteId::new(hash);
+
+ if note.id != check_noteid {
+ return false;
+ }
+
+ let Ok(sig_bytes) = hex::decode(sig) else {
+ return false;
+ };
+
+ let sig_bytes: Option<[u8; 64]> = sig_bytes.try_into().ok();
+
+ let Some(sig_bytes) = sig_bytes else {
+ return false;
+ };
+
+ if !verify_schnorr_signature(¬e.pubkey, &sig_bytes, note.id.bytes()) {
+ return false;
+ }
+
+ true
+}
+
+fn sha256(input: &[u8]) -> [u8; 32] {
+ let mut hasher = sha2::Sha256::new();
+ hasher.update(input);
+ let result = hasher.finalize();
+ result.into()
+}
+
+pub fn verify_schnorr_signature(
+ pubkey_bytes: &[u8; 32],
+ sig_bytes: &[u8; 64],
+ msg_bytes: &[u8; 32],
+) -> bool {
+ let secp = Secp256k1::verification_only();
+
+ let Ok(xonly_pubkey) = XOnlyPublicKey::from_slice(pubkey_bytes) else {
+ return false;
+ };
+ let Ok(sig) = Signature::from_slice(sig_bytes) else {
+ return false;
+ };
+
+ let msg = Message::from_digest(*msg_bytes);
+
+ secp.verify_schnorr(&sig, msg.as_ref(), &xonly_pubkey)
+ .is_ok()
+}
+
+fn preimage_matches_invoice(invoice: &Bolt11Invoice, preimage: &str) -> bool {
+ let Ok(preimage_bytes) = hex::decode(preimage.as_bytes()) else {
+ return false;
+ };
+
+ invoice.payment_secret().0 == preimage_bytes.as_bytes()
+}
+
+struct ZapTags<'a> {
+ pub bolt11: &'a str,
+ pub preimage: Option<&'a str>,
+ pub description: &'a str,
+ pub recipient: &'a [u8; 32],
+ pub note_zapped: Option<&'a [u8; 32]>,
+}
+fn get_zap_tags(ev: nostrdb::Note) -> Option<ZapTags> {
+ let mut bolt11 = None;
+ let mut preimage = None;
+ let mut description = None;
+ let mut recipient = None;
+ let mut note_zapped = None;
+
+ for tag in ev.tags() {
+ // Only process tags with at least two elements.
+ if tag.count() < 2 {
+ continue;
+ }
+
+ let Some(cur_name) = tag.get_str(0) else {
+ continue;
+ };
+
+ if cur_name == "bolt11" {
+ bolt11 = tag.get_str(1);
+ } else if cur_name == "preimage" {
+ preimage = tag.get_str(1);
+ } else if cur_name == "description" {
+ description = tag.get_str(1);
+ } else if cur_name == "p" {
+ recipient = tag.get_id(1);
+ } else if cur_name == "e" {
+ note_zapped = tag.get_id(1);
+ }
+
+ if bolt11.is_some()
+ && preimage.is_some()
+ && description.is_some()
+ && recipient.is_some()
+ && note_zapped.is_some()
+ {
+ break;
+ }
+ }
+
+ Some(ZapTags {
+ bolt11: bolt11?,
+ preimage,
+ description: description?,
+ recipient: recipient?,
+ note_zapped,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use enostr::Pubkey;
+
+ use crate::zaps::zap::{valid_zap_request, Zap};
+
+ // a random zap receipt
+ const ZAP_RECEIPT: &str = r#"{"kind":9735,"id":"c8a5767f33cd73716cf670c9615a73ec50cb91c373100f6c0d5cc160237b58dc","pubkey":"be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479","created_at":1743191143,"tags":[["p","1af54955936be804f95010647ea5ada5c7627eddf0734a7f813bba0e31eed960"],["e","ec998b249a8c366358c264f0932a9b433ac60b1c2f630cb24a604560873f7030"],["bolt11","lnbc330n1pn7dlrrpp566sfk69zda849huwjw6wepw3uzxxp4mp9np54qx49ruw8cuv86ushp52te27l4jadsz0u76jvgsk5uekl04tujpjkt9cc7duu0jfzp9zdtscqzzsxqyz5vqsp5m3tzc7ryp5f9fv90v27uyrrd4qfmj5lrwv9rvmvum3v50kdph23s9qxpqysgqut2ssf0m7nmtd73cwqk7qfw4sw6zlj598sjdxmdsepmvn0ptamnhf45c425h26juzcfupegltefwsf8qav2ldell7v9fpc0y23nl0kgqtf432g"],["description","{\"id\":\"73d05cfe976bb56b139b6cd04286a801b20cc0b01070886d6e3176ff2e107833\",\"pubkey\":\"d4338b7c3306491cfdf54914d1a52b80a965685f7361311eae5f3eaff1d23a5b\",\"created_at\":1743191138,\"kind\":9734,\"tags\":[[\"e\",\"ec998b249a8c366358c264f0932a9b433ac60b1c2f630cb24a604560873f7030\"],[\"p\",\"1af54955936be804f95010647ea5ada5c7627eddf0734a7f813bba0e31eed960\"],[\"relays\",\"wss://nosdrive.app/relay\"],[\"alt\",\"Zap request\"]],\"content\":\"\",\"sig\":\"2091b7f720586d7420ea7a90406ea856378339c8b0b3f3e695ccbfebaa8c4ea20a3cb850ff18cae957aa2e0ecb06c386d0bd27aa7a13bf7a8f7425a4c2a57903\"}"],["preimage","13821fcf87afa4c3bb753d62949481969e6af8fca9867d753e3503bd45e2814e"]],"content":"","sig":"d15aecbd1d0d289f99ffbf4d0b7c77c24875ed38fed13deee4e2e1254bcd05bda8dca3bb2858b5c3167749b4afa732f4670b9df54904786614252b4ed7916e5f"}"#;
+
+ const ZAP_REQ: &str = r#"{"id":"73d05cfe976bb56b139b6cd04286a801b20cc0b01070886d6e3176ff2e107833","pubkey":"d4338b7c3306491cfdf54914d1a52b80a965685f7361311eae5f3eaff1d23a5b","created_at":1743191138,"kind":9734,"tags":[["e","ec998b249a8c366358c264f0932a9b433ac60b1c2f630cb24a604560873f7030"],["p","1af54955936be804f95010647ea5ada5c7627eddf0734a7f813bba0e31eed960"],["relays","wss://nosdrive.app/relay"],["alt","Zap request"]],"content":"","sig":"2091b7f720586d7420ea7a90406ea856378339c8b0b3f3e695ccbfebaa8c4ea20a3cb850ff18cae957aa2e0ecb06c386d0bd27aa7a13bf7a8f7425a4c2a57903"}"#;
+
+ #[test]
+ fn test_valid_zap_req() {
+ let note = enostr::Note::from_json(ZAP_REQ).unwrap();
+
+ assert!(valid_zap_request(note));
+ }
+
+ fn enostr_note_to_nostrdb_note<'a>(note: &'a enostr::Note) -> Option<nostrdb::Note<'a>> {
+ let mut n = nostrdb::NoteBuilder::new()
+ .pubkey(¬e.pubkey)
+ .created_at(note.created_at)
+ .kind(note.kind.try_into().ok()?)
+ .content(¬e.content);
+
+ for tag in ¬e.tags {
+ n = n.start_tag();
+
+ for tag_ind in tag {
+ n = n.tag_str(&tag_ind);
+ }
+ }
+
+ n.build()
+ }
+
+ #[test]
+ fn test_zap_event() {
+ let pk =
+ Pubkey::from_hex("be1d89794bf92de5dd64c1e60f6a2c70c140abac9932418fee30c5c637fe9479")
+ .unwrap();
+
+ let note = enostr::Note::from_json(ZAP_RECEIPT).unwrap();
+ let nostrdb_note = enostr_note_to_nostrdb_note(¬e).unwrap();
+
+ let zap = Zap::from_zap_event(nostrdb_note, &pk);
+
+ assert!(zap.is_some());
+ }
+}