Files
didactyl/plans/nostr_block_list.md

9.3 KiB

NIP-51 Kind 10000 Encrypted Block List

Problem

Corrupt/unwanted events (e.g., malformed kind:7375 cashu token events) persist on relays even after NIP-09 kind:5 deletion requests. Relays accept the deletion event but continue serving the originals. The agent needs a way to permanently ignore specific events, pubkeys, and hashtags.

Solution

Implement a kind 10000 mute/block list per NIP-51, with all entries encrypted by default (NIP-44 encrypt-to-self in the content field). Provide an in-memory cache that filters events at the nostr_handler layer so blocked items are dropped system-wide.

Event Format (NIP-51 Kind 10000)

{
  "kind": 10000,
  "tags": [],
  "content": "<NIP-44 encrypted to self: JSON array of tag tuples>"
}

When decrypted, content yields:

[
  ["p", "<blocked-pubkey-hex>"],
  ["e", "<blocked-event-id-hex>"],
  ["t", "spam"]
]
  • Kind 10000 is replaceable (10000-19999 range) — each publish replaces the previous
  • Public entries go in tags, private entries go in encrypted content
  • Default: all entries are private/encrypted

Data Flow Analysis

Events enter the didactyl system through two paths:

Path 1: Subscriptions (streaming)

nostr_relay_pool_subscribe() → on_event callback → various handlers

Used by: DM subscriptions, admin context, agent context, self-skills, custom subscriptions

Path 2: Synchronous queries

nostr_relay_pool_query_sync() → nostr_handler_query_json() → caller parses JSON array

Used by: cashu_wallet_load_from_relays(), tool queries, startup checks, skill loading

Where to Filter

There is no single upstream chokepoint that catches both paths. The two best integration points are:

  1. nostr_handler_query_json() — wraps all sync queries. Add filtering in the event loop at line 2862 before adding events to the result array.

  2. Managed subscription dispatch — the internal function at line 606 that activates subscriptions. Wrap each subscription's on_event callback with a filtering wrapper.

Both are in nostr_handler.c and can call the same nostr_block_list_is_event_filtered() function.

graph TD
    subgraph "nostr_core_lib - Pool Layer"
        POOL[nostr_relay_pool]
    end

    subgraph "nostr_handler.c - FILTERING LAYER"
        QJ[nostr_handler_query_json<br/>line 2842]
        SWF[nostr_handler_subscribe_with_filter<br/>line 2880]
        MS[managed subscription dispatch<br/>line 606]
        
        QJ --> |"filter here: skip blocked events<br/>before adding to result array"| QJ_OUT[Return filtered JSON]
        MS --> |"wrap on_event: check block list<br/>before dispatching to callback"| CB[Original on_event callback]
        SWF --> |"wrap on_event: check block list"| SWF_CB[Original on_event callback]
    end

    subgraph "nostr_block_list module"
        BL[nostr_block_list_is_event_filtered]
        BL --> PS[Blocked pubkeys set]
        BL --> ES[Blocked event IDs set]
        BL --> TS[Blocked hashtags set]
    end

    POOL --> QJ
    POOL --> MS
    POOL --> SWF
    QJ --> BL
    MS --> BL
    SWF --> BL

Module Design

New Files

src/nostr_block_list.h

#ifndef DIDACTYL_NOSTR_BLOCK_LIST_H
#define DIDACTYL_NOSTR_BLOCK_LIST_H

#include "config.h"
#include "cjson/cJSON.h"

// Lifecycle
int  nostr_block_list_init(didactyl_config_t* cfg);
int  nostr_block_list_load_from_relays(void);
void nostr_block_list_cleanup(void);

// Query — O(1) lookups, thread-safe
int  nostr_block_list_is_pubkey_blocked(const char* pubkey_hex);
int  nostr_block_list_is_event_blocked(const char* event_id_hex);
int  nostr_block_list_is_hashtag_blocked(const char* hashtag);

// Generic filter — checks event's pubkey, id, and t-tags
// Returns 1 if event should be blocked, 0 if allowed
int  nostr_block_list_is_event_filtered(cJSON* event);

// Edit — modifies private (encrypted) entries by default
int  nostr_block_list_add(const char* tag_key, const char* tag_value, int is_public);
int  nostr_block_list_remove(const char* tag_key, const char* tag_value);

// Refresh in-memory cache from relays
int  nostr_block_list_refresh(void);

// Introspection — returns JSON with all blocked items
char* nostr_block_list_json(void);

#endif

src/nostr_block_list.c

Internal state:

typedef struct {
    didactyl_config_t* cfg;
    int initialized;
    int loaded;
    pthread_mutex_t mutex;

    // Public entries (from tags)
    char** pub_pubkeys;    int pub_pubkey_count;
    char** pub_event_ids;  int pub_event_id_count;
    char** pub_hashtags;   int pub_hashtag_count;

    // Private entries (from decrypted content)
    char** priv_pubkeys;   int priv_pubkey_count;
    char** priv_event_ids; int priv_event_id_count;
    char** priv_hashtags;  int priv_hashtag_count;
} block_list_state_t;

Key implementation details:

  • load_from_relays(): Query kind:10000 authored by agent pubkey, parse public tags, NIP-44 decrypt content for private tags
  • is_event_filtered(event): Extract id, pubkey, and t tags from event JSON, check against both public and private sets
  • add()/remove(): Fetch existing kind:10000, decrypt content, modify private tag list, re-encrypt, republish
  • All lookups are O(n) linear scan initially (simple); can upgrade to hash set if list grows large

New Tool Files

src/tools/tool_nostr_block.c

Two tool functions:

execute_nostr_block_list() — View current block list

// Input
{ "type": "all" }  // or "p", "e", "t"

// Output
{
  "success": true,
  "blocked_pubkeys": ["abc123..."],
  "blocked_events": ["def456..."],
  "blocked_hashtags": ["spam"],
  "total_entries": 3
}

execute_nostr_block_edit() — Add/remove block entries

// Input
{
  "action": "add",
  "items": [["e", "<event-id>"], ["p", "<pubkey>"]],
  "public": false
}

// Output
{
  "success": true,
  "action": "add",
  "items_affected": 2,
  "event_id": "<new-kind-10000-event-id>"
}

Integration Points

1. nostr_handler_query_json() — Sync query filtering

At line 2862 in nostr_handler.c, before adding each event to the result array:

for (int i = 0; i < event_count; i++) {
    if (!events[i]) continue;

    // NEW: block list filter
    if (nostr_block_list_is_event_filtered(events[i])) {
        cJSON_Delete(events[i]);
        continue;
    }

    cJSON* dup = cJSON_Duplicate(events[i], 1);
    if (dup) cJSON_AddItemToArray(arr, dup);
    cJSON_Delete(events[i]);
}

This catches: cashu wallet loading, tool queries, skill loading, startup checks.

2. Subscription event dispatch — Streaming filter

For managed subscriptions (line ~606) and nostr_handler_subscribe_with_filter() (line 2880), wrap the on_event callback:

// Wrapper that checks block list before dispatching
static void filtered_on_event(cJSON* event, const char* relay_url, void* user_data) {
    if (nostr_block_list_is_event_filtered(event)) return;
    // dispatch to original callback
    ...
}

This catches: DMs, admin context events, agent context events, self-skill events, custom subscriptions.

3. Startup sequence in main.c

nostr_handler_init(cfg);
nostr_block_list_init(cfg);
nostr_block_list_load_from_relays();  // must load before cashu wallet
cashu_wallet_init(cfg);

4. After block list edits

When execute_nostr_block_edit() publishes an updated kind:10000, call nostr_block_list_refresh() to reload the in-memory cache.

Encryption Details

Encrypt to self (for private entries)

// Encrypt: sender=agent privkey, recipient=agent pubkey
nostr_nip44_encrypt(
    cfg->keys.private_key,      // sender private key (32 bytes)
    cfg->keys.public_key,       // recipient public key (agent's own, 32 bytes)
    private_tags_json_string,   // plaintext
    output_buffer,
    output_buffer_size
);

// Decrypt: recipient=agent privkey, sender=agent pubkey
nostr_nip44_decrypt(
    cfg->keys.private_key,      // recipient private key
    cfg->keys.public_key,       // sender public key (agent's own)
    encrypted_content,          // ciphertext from event content
    output_buffer,
    output_buffer_size
);

Implementation Checklist

  • Create src/nostr_block_list.h with API declarations
  • Create src/nostr_block_list.c with:
    • State struct and lifecycle (init/cleanup)
    • load_from_relays() — fetch kind:10000, parse public tags, NIP-44 decrypt private tags
    • is_pubkey_blocked(), is_event_blocked(), is_hashtag_blocked() — lookup functions
    • is_event_filtered() — generic event check combining all three
    • add() / remove() — fetch existing, decrypt, modify, re-encrypt, republish
    • refresh() — reload from relays
    • nostr_block_list_json() — introspection
  • Create src/tools/tool_nostr_block.c with:
    • execute_nostr_block_list() — view tool
    • execute_nostr_block_edit() — edit tool
  • Register tools in tools_schema.c and tools_dispatch.c
  • Add declarations to tools_internal.h
  • Integrate filtering in nostr_handler.c:
    • Filter in nostr_handler_query_json() event loop
    • Wrap subscription on_event callbacks with block list check
  • Add nostr_block_list_init() and nostr_block_list_load_from_relays() to startup in main.c
  • Update Makefile to compile new source files
  • Test: add corrupt cashu token event IDs to block list, verify wallet skips them on reload