15 KiB
Plan: UDP Nostr as First Hop into Superball (Combination A)
Goal
Add a UDP listener to the Superball Thrower daemon so that Alice can send her kind 22222 Superball routing event as a single handshake-free UDP datagram directly to the first Thrower, instead of posting it to a Nostr relay over WSS.
This eliminates the handshake on the most dangerous hop — the one that directly exposes Alice's IP — while leaving the rest of the Superball chain (multi-hop mixing, delays, padding, audit tags, final relay posting) completely unchanged.
Architecture
Alice Thrower A Thrower B Relay3
│ │ │ │
│ 1. Build Superball routing event │ │ │
│ (kind 22222, NIP-44 encrypted, │ │ │
│ routing instructions for A→B→relay) │ │ │
│ │ │ │
│ 2. Send as single UDP datagram │ │ │
│ ─────────────────────────────────────► │ │ │
│ (no handshake, no TLS, no TCP) │ │ │
│ │ │ │
│ UDP listener parses datagram, │ │
│ validates kind 22222 event, │ │
│ calls handleIncomingEvent() │ │
│ │ │ │
│ │ 3. Normal Superball │ │
│ │ processing: decrypt, │ │
│ │ delay, rewrap, │ │
│ │ forward via WSS ─────► │ │
│ │ │ 4. decrypt, delay, │
│ │ │ post Alice's │
│ │ │ signed event ──► │
│ │ │ (final relay) │
What changes vs. standard Superball
| Component | Standard Superball | With UDP first hop |
|---|---|---|
| Alice → first Thrower | WSS to Nostr relay, Thrower picks it up via subscription | Single UDP datagram directly to Thrower |
| First Thrower → second Thrower | WSS (unchanged) | WSS (unchanged) |
| Last Thrower → final relay | WSS (unchanged) | WSS (unchanged) |
| Kind 22222 event format | Unchanged | Unchanged |
| NIP-44 encryption | Unchanged | Unchanged |
| Routing instructions | Unchanged | Unchanged |
| Thrower daemon | WSS-only listener | WSS listener + optional UDP listener |
The only change is how the first Thrower receives the event — UDP datagram instead of WSS subscription. Everything downstream is identical.
What this gains
- No handshake on the first hop. Alice's connection to the first Thrower has no SYN, no TLS ClientHello, no SNI, no recognizable protocol structure. DPI cannot fingerprint it. TCP RST injection is impossible (UDP has no RST). Active probing fails (the Thrower's response to an invalid event is indistinguishable from noise).
- Alice's IP is only seen by the first Thrower. In standard Superball, Relay1 sees Alice's IP. With UDP, the first Thrower sees Alice's IP — but the first Thrower is a trusted privacy node, not a public relay. And the first Thrower is chosen by Alice, not by the network.
- No change to the Superball protocol. The kind 22222 event, NIP-44 encryption, routing instructions, audit tags, and Thrower behavior are all unchanged. This is a transport-layer addition, not a protocol change.
What it costs
- No store-and-forward on the first hop. If the first Thrower is down, the datagram is lost. In standard Superball, Relay1 holds the event until the Thrower picks it up. Mitigation: Alice can send to multiple first-Thrower candidates, or retry.
- Alice needs to know the first Thrower's UDP endpoint. In standard Superball, Alice posts to a relay and any Thrower monitoring that relay can pick it up. With UDP, Alice needs the Thrower's IP:port. This is a discovery problem — solvable with FIPS kind 37195 adverts (see
plans/udp_nostr_fips_discovery.md), Thrower Info Documents (SUP-6), or out-of-band communication. For the minimal first step, out-of-band or hardcoded endpoints are fine. - Size limit. The kind 22222 event must fit in a single UDP datagram (1472 bytes payload). A multi-hop Superball with padding can exceed this. Mitigation: keep the first hop's routing event small (no padding on the first hop — padding is added by Throwers on subsequent hops), or use the
.bnebinary format fromdocs/binary_events.mdto save space.
Implementation steps
Step 1 — Add UDP listener to the Thrower daemon
Add a UdpListener class to thrower_daemon/daemon.js (in the super_ball repo). It:
- Creates a
dgramUDP socket bound to a configurable port (default: 8889). - On receiving a datagram, parses it as UTF-8 JSON.
- Validates it's a Nostr event:
- Has
kind === 22222 - Has a valid
id(SHA256 of the canonical event serialization) - Has a valid
sig(Schnorr signature verification againstpubkey) - Has a
ptag matching this Thrower's pubkey
- Has
- If valid, calls
this.eventProcessor.handleIncomingEvent(event)— the same method the WebSocketManager calls. - If invalid, drops silently (no response — same fail-silent rule as the rest of the daemon).
The class structure mirrors WebSocketManager:
const dgram = require('dgram');
class UdpListener {
constructor(config, logger, eventProcessor) {
this.config = config;
this.logger = logger;
this.eventProcessor = eventProcessor;
this.socket = null;
}
startListening() {
const port = this.config.get('udp.port') || 8889;
const publicKey = this.config.get('thrower.publicKey');
this.socket = dgram.createSocket('udp4');
this.socket.on('message', (msg, rinfo) => {
this.handleDatagram(msg, rinfo, publicKey);
});
this.socket.bind(port, '0.0.0.0', () => {
this.logger.info(`UDP listener started on port ${port}`);
});
}
async handleDatagram(msg, rinfo, publicKey) {
try {
const event = JSON.parse(msg.toString('utf8'));
// Validate kind, p tag, signature
if (event.kind !== 22222) return;
const pTags = (event.tags || []).filter(t => t[0] === 'p').map(t => t[1]);
if (!pTags.includes(publicKey)) return;
// Verify signature using nostr-tools validateEvent
if (!validateEvent(event) || !verifySignature(event)) return;
this.logger.info(`UDP: Received kind 22222 from ${rinfo.address}:${rinfo.port}`);
this.eventProcessor.handleIncomingEvent(event);
} catch (e) {
// Fail silently — drop invalid datagrams
}
}
stopListening() {
if (this.socket) {
this.socket.close();
this.socket = null;
this.logger.info('UDP listener stopped');
}
}
}
Deliverable: UdpListener class added to daemon.js in the super_ball repo.
Step 2 — Wire UDP listener into the daemon startup
In ThrowerDaemon:
- Constructor: instantiate
this.udpListener = new UdpListener(this.config, this.logger, this.eventProcessor). start(): afterthis.wsManager.startMonitoring(), callthis.udpListener.startListening()if UDP is enabled in config.stop(): callthis.udpListener.stopListening().
Config addition in config.json:
{
"udp": {
"enabled": true,
"port": 8889
}
}
Default: enabled: false (opt-in, like FIPS's discovery).
Deliverable: Updated ThrowerDaemon class and config schema.
Step 3 — Add a UDP sender for Superball events
Write a sender script (in this repo, src/) that:
- Reads a kind 22222 Superball routing event from stdin (JSON).
- Sends it as a single UDP datagram to the first Thrower's IP:port.
- Sets the DF flag (like
src/udp_nostr_send.py) to enforce single-packet delivery.
This is essentially the existing udp_nostr_send.py — it already does exactly this. The only difference is the content is a kind 22222 event instead of a kind 1 event. No new code needed — udp_nostr_send.py is content-agnostic; it sends whatever JSON it reads from stdin.
Deliverable: Documentation showing how to pipe a Superball event through the existing sender.
Step 4 — Build a Superball-over-UDP builder script
Write a script (in this repo, src/) that:
- Takes Alice's content message, her secret key, the first Thrower's pubkey, and the first Thrower's UDP endpoint (IP:port).
- Builds the final kind 1 event (Alice's signed message).
- Builds the routing instructions for the first Thrower (relays, delay, next hop or final posting).
- Wraps everything as a kind 22222 event encrypted to the first Thrower's pubkey (NIP-44).
- Pipes the kind 22222 event to
udp_nostr_send.pytargeting the Thrower's UDP endpoint.
This is the "builder" role from the Superball protocol. The super_ball repo has a web-based builder (web/superball.html); this would be a CLI version that outputs to UDP instead of WSS.
Deliverable: src/superball_udp_send.py (or .sh using nak).
Step 5 — Test end-to-end
- Start a Thrower daemon with UDP enabled (
udp.enabled: true,udp.port: 8889). - Build a Superball event targeting that Thrower.
- Send it via UDP:
cat superball_event.json | python3 src/udp_nostr_send.py 127.0.0.1 8889. - Verify the Thrower daemon receives, decrypts, and processes it (check logs).
- If multi-hop: verify the event forwards to the next Thrower / final relay.
Deliverable: src/test_superball_udp.sh test script.
Step 6 — Document the combination
Add a section to the super_ball repo's README (or a new UDP_FIRST_HOP.md) explaining:
- The problem: standard Superball's first hop is WSS (handshake visible to DPI).
- The solution: send the first hop as a UDP datagram (no handshake).
- The architecture diagram above.
- How to enable UDP on the Thrower daemon.
- How to build and send a Superball over UDP.
- The tradeoffs (no store-and-forward on first hop, size limit, discovery).
- Future: FIPS discovery for Thrower UDP endpoints (cross-reference
plans/udp_nostr_fips_discovery.md).
Deliverable: Documentation in the super_ball repo.
Size constraint analysis
A kind 22222 Superball routing event for the first hop contains:
| Component | Approximate size |
|---|---|
| Event JSON structure (kind, pubkey, created_at, id, sig, tags) | ~200 bytes |
| NIP-44 encrypted content (routing instructions + inner event) | varies |
| Inner event (Alice's kind 1, or next routing event) | ~300-500 bytes |
| Routing instructions JSON | ~100-200 bytes |
| NIP-44 encryption overhead (~10% + 48 bytes) | ~50-100 bytes |
| Total (single-hop, no padding) | ~650-1000 bytes |
| Total (two-hop, with padding) | ~1000-1400 bytes |
A single-hop Superball (Alice → Thrower → final relay) fits comfortably in 1472 bytes. A two-hop Superball with padding is borderline. Mitigations:
- Keep the first hop's routing event padding-free (padding is added by Throwers on subsequent hops, not by Alice).
- Use the
.bnebinary format (docs/binary_events.md) for the inner event to save ~200 bytes. - For larger events, send the first hop over WSS (fall back to standard Superball).
Open questions / decisions
-
Where does the code live? The UDP listener goes in the super_ball repo (it's a Thrower daemon feature). The builder/sender scripts go in this repo (they're sender-side tools). Is that the right split, or should everything go in one repo?
-
Signature verification on UDP. The Thrower must verify the kind 22222 event's signature before processing.
nostr-toolsprovidesvalidateEventandverifySignature— but these are synchronous and may be slow under flood. Should we add rate-limiting on the UDP listener (per-IP or global)? The existing daemon doesn't rate-limit WSS events, but UDP is easier to flood. -
Discovery. For the minimal first step, Alice knows the first Thrower's UDP endpoint out-of-band. Should we plan FIPS kind 37195 adverts for Thrower UDP endpoints as a follow-up? (This would be Combination C from the analysis.)
-
Multi-Thrower redundancy. Should Alice send the same Superball to multiple first-Thrower UDP endpoints simultaneously (like Superball's SUP-4 multi-path)? This would mitigate the no-store-and-forward risk.
What this does NOT do
- It does not change the Superball protocol. Kind 22222, NIP-44, routing instructions, audit tags, and Thrower behavior are all unchanged.
- It does not eliminate handshakes on subsequent hops. Thrower-to-Thrower and Thrower-to-relay communication is still WSS. Only the first hop (Alice → first Thrower) is UDP.
- It does not solve discovery. Alice needs to know the first Thrower's UDP endpoint. FIPS adverts are a separate follow-up.
- It does not provide two-way communication. Alice cannot receive replies via UDP. Audit tags still require Alice to monitor relays via WSS (or Tor).
File summary
| File | Repo | Status | Purpose |
|---|---|---|---|
thrower_daemon/daemon.js |
super_ball | modify | Add UdpListener class, wire into ThrowerDaemon |
thrower_daemon/config.example.json |
super_ball | modify | Add udp.enabled and udp.port fields |
src/superball_udp_send.py |
udp_nostr | new | CLI builder: construct Superball, send via UDP |
src/test_superball_udp.sh |
udp_nostr | new | End-to-end test |
UDP_FIRST_HOP.md |
super_ball | new | Document the UDP first-hop combination |
Relationship to other plans
This plan is the minimal first step toward the full vision (Combination C). The follow-up plan (plans/udp_nostr_fips_discovery.md) adds FIPS kind 37195 adverts so Throwers can advertise their UDP endpoints and move IPs freely. Together they form the full stack:
| Layer | Plan | Role |
|---|---|---|
| Discovery | udp_nostr_fips_discovery.md |
npub → current UDP endpoint (Throwers can move) |
| First hop | this plan | No-handshake UDP datagram to first Thrower |
| Mixing | Superball (existing) | Multi-hop location privacy with delays, padding, audit |
| Final delivery | Nostr relays (existing) | Public, durable, queryable by any Nostr client |