Skip to content

Commit 7929952

Browse files
committed
Fix batch verify in WASM with error logging
- Add verify_batch_with_errors to log detailed verification failures - Log errors to browser console instead of swallowing them - Use serde_json for canonical event serialization in make_id - Add tests for events with special characters and user's failing event
1 parent cc49ae3 commit 7929952

11 files changed

Lines changed: 136 additions & 113 deletions

File tree

packages/app/src/Utils/wasm.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type PowMiner,
77
PowWorker,
88
type ReqFilter,
9+
type TaggedNostrEvent,
910
} from "@snort/system"
1011
import PowWorkerURL from "@snort/system/src/pow-worker.ts?worker&url"
1112

@@ -37,9 +38,9 @@ export const WasmOptimizer = {
3738
return compress(all) as Array<ReqFilter>
3839
},
3940
schnorrVerify: ev => {
40-
// Fast path: already verified (e.g. by a prior call in the same pipeline)
4141
if (EventExt.isVerified(ev)) return true
42-
const ok = schnorr_verify_event(ev) as boolean
42+
const { relays, ...clean } = ev as TaggedNostrEvent
43+
const ok = schnorr_verify_event(clean) as boolean
4344
if (ok) EventExt.markVerified(ev)
4445
return ok
4546
},
@@ -56,7 +57,9 @@ export const WasmOptimizer = {
5657
}
5758
if (unverified.length > 0) {
5859
// One JS→WASM call for all unverified events; returns Uint8Array (1=valid, 0=invalid).
59-
const raw = schnorr_verify_batch(unverified.map(u => u.ev)) as Uint8Array
60+
const raw = schnorr_verify_batch(
61+
unverified.map(u => u.ev),
62+
) as Uint8Array
6063
for (let j = 0; j < unverified.length; j++) {
6164
const ok = raw[j] === 1
6265
results[unverified[j].idx] = ok

packages/system-wasm/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/system-wasm/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ serde-wasm-bindgen = "0.6.5"
1616
serde_json = { version = "1.0.105", default-features = false, features = ["alloc"] }
1717
sha256 = { version = "1.6.0", features = [], default-features = false }
1818
wasm-bindgen = "0.2.114"
19+
web-sys = { version = "0.3", features = ["console"] }
1920

2021
[dev-dependencies]
2122
rand = "0.8.5"

packages/system-wasm/pkg/system_wasm.d.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,6 @@ export function pow(val: any, target: any): any;
2222
*/
2323
export function schnorr_verify(hash: any, sig: any, pub_key: any): boolean;
2424

25-
/**
26-
* Verify a batch of Nostr events in a single JS→WASM call.
27-
*
28-
* This is the primary performance optimisation for bulk verification.
29-
* Crossing the JS/WASM boundary has fixed overhead (serialisation, memory
30-
* copies); calling `schnorr_verify_event` N times pays that cost N times.
31-
* `schnorr_verify_batch` pays it once regardless of N, then runs the
32-
* cryptographic work entirely inside WASM.
33-
*
34-
* Returns a `Uint8Array` (one byte per event: `1` = valid, `0` = invalid)
35-
* rather than `Array<boolean>` — typed arrays cross the WASM boundary
36-
* without per-element boxing overhead.
37-
*
38-
* Call-site example (TypeScript):
39-
* ```ts
40-
* const results = schnorr_verify_batch(events) // Uint8Array
41-
* const valid = Array.from(results).map(b => b === 1)
42-
* ```
43-
*/
4425
export function schnorr_verify_batch(events: any): Uint8Array;
4526

4627
/**

packages/system-wasm/pkg/system_wasm.js

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -95,23 +95,6 @@ export function schnorr_verify(hash, sig, pub_key) {
9595
}
9696

9797
/**
98-
* Verify a batch of Nostr events in a single JS→WASM call.
99-
*
100-
* This is the primary performance optimisation for bulk verification.
101-
* Crossing the JS/WASM boundary has fixed overhead (serialisation, memory
102-
* copies); calling `schnorr_verify_event` N times pays that cost N times.
103-
* `schnorr_verify_batch` pays it once regardless of N, then runs the
104-
* cryptographic work entirely inside WASM.
105-
*
106-
* Returns a `Uint8Array` (one byte per event: `1` = valid, `0` = invalid)
107-
* rather than `Array<boolean>` — typed arrays cross the WASM boundary
108-
* without per-element boxing overhead.
109-
*
110-
* Call-site example (TypeScript):
111-
* ```ts
112-
* const results = schnorr_verify_batch(events) // Uint8Array
113-
* const valid = Array.from(results).map(b => b === 1)
114-
* ```
11598
* @param {any} events
11699
* @returns {Uint8Array}
117100
*/
@@ -236,6 +219,9 @@ function __wbg_get_imports() {
236219
wasm.__wbindgen_free(deferred0_0, deferred0_1, 1);
237220
}
238221
},
222+
__wbg_error_cfce0f619500de52: function(arg0, arg1) {
223+
console.error(arg0, arg1);
224+
},
239225
__wbg_get_326e41e095fb2575: function() { return handleError(function (arg0, arg1) {
240226
const ret = Reflect.get(arg0, arg1);
241227
return ret;
28 KB
Binary file not shown.

packages/system-wasm/src/lib.rs

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ pub fn schnorr_verify(hash: JsValue, sig: JsValue, pub_key: JsValue) -> Result<b
103103
Ok(SECP256K1.verify_schnorr(&sig, &msg_bytes, &key).is_ok())
104104
}
105105

106+
fn log_error(msg: &str, err: &str) {
107+
web_sys::console::error_2(&JsValue::from_str(msg), &JsValue::from_str(err));
108+
}
109+
106110
/// Verify a single Nostr event.
107111
///
108112
/// Computes the canonical event ID from scratch (does not trust `event.id`)
@@ -112,35 +116,25 @@ pub fn schnorr_verify(hash: JsValue, sig: JsValue, pub_key: JsValue) -> Result<b
112116
pub fn schnorr_verify_event(event: JsValue) -> Result<bool, JsValue> {
113117
console_error_panic_hook::set_once();
114118
let event_obj: Event = serde_wasm_bindgen::from_value(event)?;
115-
Ok(verify::verify_event(&event_obj, false).unwrap_or(false))
119+
match verify::verify_event(&event_obj, false) {
120+
Ok(result) => Ok(result),
121+
Err(e) => {
122+
log_error("schnorr_verify_event failed", &format!("{:?}", e));
123+
Ok(false)
124+
}
125+
}
116126
}
117127

118-
/// Verify a batch of Nostr events in a single JS→WASM call.
119-
///
120-
/// This is the primary performance optimisation for bulk verification.
121-
/// Crossing the JS/WASM boundary has fixed overhead (serialisation, memory
122-
/// copies); calling `schnorr_verify_event` N times pays that cost N times.
123-
/// `schnorr_verify_batch` pays it once regardless of N, then runs the
124-
/// cryptographic work entirely inside WASM.
125-
///
126-
/// Returns a `Uint8Array` (one byte per event: `1` = valid, `0` = invalid)
127-
/// rather than `Array<boolean>` — typed arrays cross the WASM boundary
128-
/// without per-element boxing overhead.
129-
///
130-
/// Call-site example (TypeScript):
131-
/// ```ts
132-
/// const results = schnorr_verify_batch(events) // Uint8Array
133-
/// const valid = Array.from(results).map(b => b === 1)
134-
/// ```
135128
#[wasm_bindgen]
136129
pub fn schnorr_verify_batch(events: JsValue) -> Result<Box<[u8]>, JsValue> {
137130
console_error_panic_hook::set_once();
138131
let events_parsed: Vec<Event> = serde_wasm_bindgen::from_value(events)?;
139-
let results: Vec<u8> = verify::verify_batch(&events_parsed)
132+
let results: Vec<bool> = verify::verify_batch_with_errors(&events_parsed);
133+
Ok(results
140134
.into_iter()
141135
.map(|b| b as u8)
142-
.collect();
143-
Ok(results.into_boxed_slice())
136+
.collect::<Vec<_>>()
137+
.into_boxed_slice())
144138
}
145139

146140
#[cfg(test)]

packages/system-wasm/src/pow.rs

Lines changed: 35 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
use std::fmt::Write as FmtWrite;
2-
31
use crate::Event;
42

53
pub fn pow(ev: &mut Event, target: u8) {
@@ -17,13 +15,11 @@ pub fn pow(ev: &mut Event, target: u8) {
1715
}
1816
};
1917

20-
// Reuse a single String buffer across iterations to avoid repeated heap allocation.
21-
let mut id_buf = String::with_capacity(512);
2218
loop {
2319
ev.tags[nonce_tag_idx][1] = ctr.to_string();
2420

25-
make_id_into(ev, &mut id_buf);
26-
let digest = sha256::digest(id_buf.as_bytes());
21+
let id_hex = make_id(ev);
22+
let digest = sha256::digest(id_hex.as_bytes());
2723
if count_leading_zeros(&digest) >= target {
2824
ev.id = Some(digest);
2925
break;
@@ -62,52 +58,10 @@ fn hex_nibble(c: u8) -> u8 {
6258
}
6359
}
6460

65-
/// Write the canonical Nostr event serialisation into `buf`, clearing it first.
66-
/// Avoids allocating intermediate `Vec<String>` for the tags.
67-
fn make_id_into(ev: &Event, buf: &mut String) {
68-
buf.clear();
69-
buf.push_str("[0,\"");
70-
buf.push_str(&ev.pubkey);
71-
buf.push_str("\",");
72-
let _ = write!(buf, "{}", ev.created_at);
73-
buf.push(',');
74-
let _ = write!(buf, "{}", ev.kind);
75-
buf.push_str(",[");
76-
for (ti, tag) in ev.tags.iter().enumerate() {
77-
if ti > 0 {
78-
buf.push(',');
79-
}
80-
buf.push('[');
81-
for (vi, val) in tag.iter().enumerate() {
82-
if vi > 0 {
83-
buf.push(',');
84-
}
85-
buf.push('"');
86-
for ch in val.chars() {
87-
match ch {
88-
'"' => buf.push_str("\\\""),
89-
'\\' => buf.push_str("\\\\"),
90-
'\n' => buf.push_str("\\n"),
91-
'\r' => buf.push_str("\\r"),
92-
'\t' => buf.push_str("\\t"),
93-
c => buf.push(c),
94-
}
95-
}
96-
buf.push('"');
97-
}
98-
buf.push(']');
99-
}
100-
buf.push_str("],\"");
101-
buf.push_str(&ev.content);
102-
buf.push_str("\"]");
103-
}
104-
105-
/// Compute the canonical Nostr event ID (SHA-256 hex of the serialised commitment).
106-
/// Public so `verify.rs` and benchmarks can reuse it directly.
61+
/// Write the canonical Nostr event serialisation using serde_json for correct escaping.
10762
pub fn make_id(ev: &Event) -> String {
108-
let mut buf = String::with_capacity(512);
109-
make_id_into(ev, &mut buf);
110-
sha256::digest(buf.as_bytes())
63+
let payload = serde_json::json!([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content]);
64+
sha256::digest(payload.to_string().as_bytes())
11165
}
11266

11367
#[cfg(test)]
@@ -137,6 +91,36 @@ mod tests {
13791
assert_eq!(super::make_id(&ev), ev.id.unwrap())
13892
}
13993

94+
#[test]
95+
fn make_id_with_special_chars_in_content() {
96+
let ev = Event::deserialize(json!({
97+
"content": "{\"about\":\"mar - the main character, might be a girl\\n\\ncatoshi - the black cat, definitely a cat\",\"banner\":\"https://mar101xy.com/images/mar101xy-profile-cover.jpg\",\"bot\":false,\"display_name\":\"mar\",\"lud16\":\"mar101xy@walletofsatoshi.com\",\"nip05\":\"mar@mar101xy.com\",\"picture\":\"https://mar101xy.com/images/avatar.jpg\",\"displayName\":\"mar\",\"fields\":[[\"test\",\"testing ditto\"],[\"gender\",\"testing gender\"]],\"name\":\"mar\"}",
98+
"created_at": 1775155758,
99+
"id": "053516868fe8f94fa180835d3b0be4042aaaddc514c6e6d6d8e0fa9694d3442d",
100+
"kind": 0,
101+
"pubkey": "c7acabf1fed201a53185e4dc5e0c6bae2bc5db19d73abf840535f305d8f05180",
102+
"sig": "c8de210d80a2ad92e1145e9c52177ab077f862acd9857f7e8b6ae24645893b0d81bf585814ececb17b0ecd17529135b39cdd9aaf2a84626509c11c6f0c6ed62f",
103+
"tags": [["client","Ditto"]]
104+
})).ok().unwrap();
105+
106+
assert_eq!(super::make_id(&ev), ev.id.unwrap())
107+
}
108+
109+
#[test]
110+
fn make_id_with_escaped_backslash_in_content() {
111+
let ev = Event::deserialize(json!({
112+
"content": "{\"name\":\"TheGrinder\",\"about\":\"Sovereign, creator of bitcoins, future owner of Mars and grinder of many things...\\n0863F34D0311FC550226F06A376B54D5650980FB\",\"picture\":\"https://i.nostr.build/TghNVYXqMe7knx7P.jpg\",\"banner\":\"https://nostr.build/i/094828ef504cb05424a9680db23d37db3cf02f05ede1d33528c5c5f9872db66e.jpg\",\"displayName\":\"TheGrinder\",\"lud16\":\"thegrinder@rizful.com\",\"display_name\":\"TheGrinder\",\"website\":\"https://zap.stream/thegrinder\",\"nip05\":\"thegrinder@nostrplebs.com\"}",
113+
"created_at": 1774868231,
114+
"id": "2dc93fea65b858e864520687927bb8e83374fff8eeb592c9cfaa8b470fc7b2db",
115+
"kind": 0,
116+
"pubkey": "6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e",
117+
"sig": "4b2c21ff288b2e4e900f88429a0b4a8c3d6c248cab175b8e984d3251cf67578626583379cef99c73e85df3fc35407f292499df7124c3d854bfbf6ac0421a03ee",
118+
"tags": [["client","noStrudel","31990:266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5:1686066542546"]]
119+
})).ok().unwrap();
120+
121+
assert_eq!(super::make_id(&ev), ev.id.unwrap())
122+
}
123+
140124
#[test]
141125
fn count_zeros() {
142126
assert_eq!(10u8.leading_zeros(), 4);

packages/system-wasm/src/verify.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,26 @@ pub fn verify_batch(events: &[Event]) -> Vec<bool> {
9999
.collect()
100100
}
101101

102+
/// Verify a batch of events, logging detailed errors for each failure.
103+
/// Returns a Vec<bool> where true means valid, false means invalid/error.
104+
pub fn verify_batch_with_errors(events: &[Event]) -> Vec<bool> {
105+
events
106+
.iter()
107+
.enumerate()
108+
.map(|(idx, ev)| match verify_event(ev, false) {
109+
Ok(true) => true,
110+
Ok(false) => {
111+
eprintln!("batch[{}]: signature verification failed", idx);
112+
false
113+
}
114+
Err(e) => {
115+
eprintln!("batch[{}]: verification error: {:?}", idx, e);
116+
false
117+
}
118+
})
119+
.collect()
120+
}
121+
102122
#[cfg(test)]
103123
mod tests {
104124
use super::*;
@@ -186,4 +206,57 @@ mod tests {
186206
fn batch_verify_empty() {
187207
assert_eq!(verify_batch(&[]), Vec::<bool>::new());
188208
}
209+
210+
#[test]
211+
fn batch_verify_events_with_special_content() {
212+
let profile_with_newlines = Event::deserialize(json!({
213+
"content": "{\"about\":\"mar - the main character, might be a girl\\n\\ncatoshi - the black cat, definitely a cat\",\"banner\":\"https://mar101xy.com/images/mar101xy-profile-cover.jpg\",\"bot\":false,\"display_name\":\"mar\",\"lud16\":\"mar101xy@walletofsatoshi.com\",\"nip05\":\"mar@mar101xy.com\",\"picture\":\"https://mar101xy.com/images/avatar.jpg\",\"displayName\":\"mar\",\"fields\":[[\"test\",\"testing ditto\"],[\"gender\",\"testing gender\"]],\"name\":\"mar\"}",
214+
"created_at": 1775155758,
215+
"id": "053516868fe8f94fa180835d3b0be4042aaaddc514c6e6d6d8e0fa9694d3442d",
216+
"kind": 0,
217+
"pubkey": "c7acabf1fed201a53185e4dc5e0c6bae2bc5db19d73abf840535f305d8f05180",
218+
"sig": "c8de210d80a2ad92e1145e9c52177ab077f862acd9857f7e8b6ae24645893b0d81bf585814ececb17b0ecd17529135b39cdd9aaf2a84626509c11c6f0c6ed62f",
219+
"tags": [["client","Ditto"]]
220+
})).unwrap();
221+
222+
let profile_with_escaped_backslash = Event::deserialize(json!({
223+
"content": "{\"name\":\"TheGrinder\",\"about\":\"Sovereign, creator of bitcoins, future owner of Mars and grinder of many things...\\n0863F34D0311FC550226F06A376B54D5650980FB\",\"picture\":\"https://i.nostr.build/TghNVYXqMe7knx7P.jpg\",\"banner\":\"https://nostr.build/i/094828ef504cb05424a9680db23d37db3cf02f05ede1d33528c5c5f9872db66e.jpg\",\"displayName\":\"TheGrinder\",\"lud16\":\"thegrinder@rizful.com\",\"display_name\":\"TheGrinder\",\"website\":\"https://zap.stream/thegrinder\",\"nip05\":\"thegrinder@nostrplebs.com\"}",
224+
"created_at": 1774868231,
225+
"id": "2dc93fea65b858e864520687927bb8e83374fff8eeb592c9cfaa8b470fc7b2db",
226+
"kind": 0,
227+
"pubkey": "6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e",
228+
"sig": "4b2c21ff288b2e4e900f88429a0b4a8c3d6c248cab175b8e984d3251cf67578626583379cef99c73e85df3fc35407f292499df7124c3d854bfbf6ac0421a03ee",
229+
"tags": [["client","noStrudel","31990:266815e0c9210dfa324c6cba3573b14bee49da4209a9456f9484e5106cd408a5:1686066542546"]]
230+
})).unwrap();
231+
232+
let plain = make_valid_event();
233+
234+
let results = verify_batch(&[profile_with_newlines, profile_with_escaped_backslash, plain]);
235+
assert_eq!(results, vec![true, true, true]);
236+
}
237+
238+
#[test]
239+
fn batch_verify_user_event() {
240+
let ev = Event::deserialize(json!({
241+
"kind": 1,
242+
"id": "c55e31fe1c93705558d58c8ad309b0b27c2f21dae92f72bfbc8869de872a2616",
243+
"pubkey": "06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71",
244+
"created_at": 1774961298,
245+
"tags": [
246+
["e", "507c11d1cdb2130c751ff05e7afa0a47079025ba39ef099ae1a25c53c03ae99e", "wss://pyramid.fiatjaf.com/", "root", "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"],
247+
["e", "0468572f697fa7e399f7a99b0d7f1d640a10ceeab17d31e68e11af37be444b26", "wss://nos.lol/", "reply", "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"],
248+
["p", "63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed"]
249+
],
250+
"content": "I'm interested to try it.\nLocally I have qwen3.5 8B running with ollama on a RK1 32GB compute module.",
251+
"sig": "c07c9757ebb3e0808077d5876d5c670c0e788d9f92326ff21aae4766e026e722d017c2e9c1229eece8861c308732d920f1bdbb30ceddd4342d2c4c95f4c5fa05"
252+
})).unwrap();
253+
254+
let computed_id = crate::pow::make_id(&ev);
255+
println!("computed_id: {}", computed_id);
256+
println!("event id: {:?}", ev.id);
257+
258+
let results = verify_batch(&[ev]);
259+
println!("verify result: {:?}", results);
260+
assert_eq!(results, vec![true]);
261+
}
189262
}

packages/system/src/background-loader.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ export abstract class BackgroundLoader<T extends { loaded: number; created: numb
149149
* Get object from cache or fetch if missing
150150
*/
151151
async fetch(key: string, timeoutMs = 30_000) {
152-
const existing = this.cache.get(key)
152+
const existing = await this.cache.get(key)
153153
if (existing) {
154154
return existing
155155
} else {

0 commit comments

Comments
 (0)