Files
udp_nostr/plans/udp_nostr_superball.md
T
2026-08-12 16:34:22 -04:00

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

  1. 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).
  2. 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.
  3. 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

  1. 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.
  2. 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.
  3. 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 .bne binary format from docs/binary_events.md to 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:

  1. Creates a dgram UDP socket bound to a configurable port (default: 8889).
  2. On receiving a datagram, parses it as UTF-8 JSON.
  3. 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 against pubkey)
    • Has a p tag matching this Thrower's pubkey
  4. If valid, calls this.eventProcessor.handleIncomingEvent(event) — the same method the WebSocketManager calls.
  5. 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:

  1. Constructor: instantiate this.udpListener = new UdpListener(this.config, this.logger, this.eventProcessor).
  2. start(): after this.wsManager.startMonitoring(), call this.udpListener.startListening() if UDP is enabled in config.
  3. stop(): call this.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:

  1. Reads a kind 22222 Superball routing event from stdin (JSON).
  2. Sends it as a single UDP datagram to the first Thrower's IP:port.
  3. 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 neededudp_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:

  1. Takes Alice's content message, her secret key, the first Thrower's pubkey, and the first Thrower's UDP endpoint (IP:port).
  2. Builds the final kind 1 event (Alice's signed message).
  3. Builds the routing instructions for the first Thrower (relays, delay, next hop or final posting).
  4. Wraps everything as a kind 22222 event encrypted to the first Thrower's pubkey (NIP-44).
  5. Pipes the kind 22222 event to udp_nostr_send.py targeting 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

  1. Start a Thrower daemon with UDP enabled (udp.enabled: true, udp.port: 8889).
  2. Build a Superball event targeting that Thrower.
  3. Send it via UDP: cat superball_event.json | python3 src/udp_nostr_send.py 127.0.0.1 8889.
  4. Verify the Thrower daemon receives, decrypts, and processes it (check logs).
  5. 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 .bne binary 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

  1. 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?

  2. Signature verification on UDP. The Thrower must verify the kind 22222 event's signature before processing. nostr-tools provides validateEvent and verifySignature — 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.

  3. 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.)

  4. 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