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 encryptedcontent - 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:
-
nostr_handler_query_json()— wraps all sync queries. Add filtering in the event loop at line 2862 before adding events to the result array. -
Managed subscription dispatch — the internal function at line 606 that activates subscriptions. Wrap each subscription's
on_eventcallback 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 tagsis_event_filtered(event): Extractid,pubkey, andttags from event JSON, check against both public and private setsadd()/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.hwith API declarations - Create
src/nostr_block_list.cwith:- State struct and lifecycle (init/cleanup)
load_from_relays()— fetch kind:10000, parse public tags, NIP-44 decrypt private tagsis_pubkey_blocked(),is_event_blocked(),is_hashtag_blocked()— lookup functionsis_event_filtered()— generic event check combining all threeadd()/remove()— fetch existing, decrypt, modify, re-encrypt, republishrefresh()— reload from relaysnostr_block_list_json()— introspection
- Create
src/tools/tool_nostr_block.cwith:execute_nostr_block_list()— view toolexecute_nostr_block_edit()— edit tool
- Register tools in
tools_schema.candtools_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_eventcallbacks with block list check
- Filter in
- Add
nostr_block_list_init()andnostr_block_list_load_from_relays()to startup inmain.c - Update
Makefileto compile new source files - Test: add corrupt cashu token event IDs to block list, verify wallet skips them on reload