Compare commits

..
1 Commits
5 changed files with 461 additions and 15 deletions
+1 -1
View File
@@ -1 +1 @@
0.6.12
0.6.13
+32 -11
View File
@@ -31,8 +31,9 @@ static cJSON* add_simple_tag(cJSON* tags, const char* name, const char* value) {
}
/**
* Helper: add a multi-value tag (same name, multiple values).
* Helper: add multiple tags with the same name (one value each).
* Creates ["<name>", "<value>"] for each value and appends it.
* Used for tags that NIP-34 specifies as repeated single-value tags.
*/
static int add_multi_tag(cJSON* tags, const char* name, const char** values, int count) {
if (!tags || !name || !values || count <= 0) return -1;
@@ -48,6 +49,26 @@ static int add_multi_tag(cJSON* tags, const char* name, const char** values, int
return 0;
}
/**
* Helper: add a single tag with multiple values.
* Creates ["<name>", "<value1>", "<value2>", ...] and appends it.
* Used for NIP-34 tags like clone, relays, maintainers that pack
* multiple values into one tag array.
*/
static int add_single_multi_value_tag(cJSON* tags, const char* name, const char** values, int count) {
if (!tags || !name || !values || count <= 0) return -1;
cJSON* tag = cJSON_CreateArray();
if (!tag) return -1;
cJSON_AddItemToArray(tag, cJSON_CreateString(name));
for (int i = 0; i < count; i++) {
if (values[i]) {
cJSON_AddItemToArray(tag, cJSON_CreateString(values[i]));
}
}
cJSON_AddItemToArray(tags, tag);
return 0;
}
/**
* Helper: add a three-value tag.
* Creates ["<name>", "<value1>", "<value2>"] and appends it.
@@ -107,14 +128,14 @@ cJSON* nostr_nip34_create_repo_announcement(
add_simple_tag(tags, "web", web_url);
}
// clone tags (optional, can be multiple)
// clone tag (optional, multiple URLs in a single tag per NIP-34)
if (clone_urls && clone_count > 0) {
add_multi_tag(tags, "clone", clone_urls, clone_count);
add_single_multi_value_tag(tags, "clone", clone_urls, clone_count);
}
// relays tags (optional, can be multiple)
// relays tag (optional, multiple URLs in a single tag per NIP-34)
if (relay_urls && relay_count > 0) {
add_multi_tag(tags, "relays", relay_urls, relay_count);
add_single_multi_value_tag(tags, "relays", relay_urls, relay_count);
}
// r tag with euc marker (optional)
@@ -128,9 +149,9 @@ cJSON* nostr_nip34_create_repo_announcement(
}
}
// maintainers tags (optional, can be multiple)
// maintainers tag (optional, multiple pubkeys in a single tag per NIP-34)
if (maintainers && maintainer_count > 0) {
add_multi_tag(tags, "maintainers", maintainers, maintainer_count);
add_single_multi_value_tag(tags, "maintainers", maintainers, maintainer_count);
}
cJSON* event = nostr_create_and_sign_event_with_signer(
@@ -299,9 +320,9 @@ cJSON* nostr_nip34_create_pull_request(
add_simple_tag(tags, "c", tip_commit);
}
// clone tags (optional, can be multiple)
// clone tag (optional, multiple URLs in a single tag per NIP-34)
if (clone_urls && clone_count > 0) {
add_multi_tag(tags, "clone", clone_urls, clone_count);
add_single_multi_value_tag(tags, "clone", clone_urls, clone_count);
}
// branch-name tag (optional)
@@ -365,9 +386,9 @@ cJSON* nostr_nip34_create_pr_update(
add_simple_tag(tags, "c", tip_commit);
}
// clone tags (optional, can be multiple)
// clone tag (optional, multiple URLs in a single tag per NIP-34)
if (clone_urls && clone_count > 0) {
add_multi_tag(tags, "clone", clone_urls, clone_count);
add_single_multi_value_tag(tags, "clone", clone_urls, clone_count);
}
// merge-base tag (optional)
+2 -2
View File
@@ -2,10 +2,10 @@
#define NOSTR_CORE_H
// Version information (auto-updated by increment_and_push.sh)
#define VERSION "v0.6.12"
#define VERSION "v0.6.13"
#define VERSION_MAJOR 0
#define VERSION_MINOR 6
#define VERSION_PATCH 12
#define VERSION_PATCH 13
/*
* NOSTR Core Library - Complete API Reference
+391
View File
@@ -0,0 +1,391 @@
# Negentropy (NIP-77) Implementation Plan — nostr_core_lib
## Goal
Implement [NIP-77](https://github.com/nostr-protocol/nips/blob/master/77.md)
Negentropy / **Range-Based Set Reconciliation (RBSR)** as a reusable C module
inside [`nostr_core_lib`](../nostr_core), so that both
[`c-relay-pg`](../../c-relay-pg) (server side) and its
[caching daemon](../../c-relay-pg/caching) (client side) can use it for
bandwidth-optimal, bidirectional relay-to-relay event sync.
This plan covers **only the work that lives in `nostr_core_lib`**: the RBSR
algorithm, the NIP-77 wire framing, and unit tests. The downstream relay and
caching-daemon integration is tracked separately in
[`c-relay-pg/plans/relay_to_relay_sync_plan.md`](../../c-relay-pg/plans/relay_to_relay_sync_plan.md)
(Phases 24 there reference the Phase 1 deliverables produced here).
---
## Background
### What RBSR does
Two parties each hold a set of items (here: Nostr event IDs). They want to
learn *exactly* which IDs the other side has that they lack, and vice versa,
without transferring the full sets. RBSR does this by:
1. Sorting items into a sequence ordered by `(timestamp, id)`.
2. Recursively splitting the range tree and comparing small **fingerprints**
(a 64-bit hash of the IDs in a subrange) at each split.
3. When fingerprints match → both sides have the same IDs in that subrange,
skip it. When they differ → recurse / drill down until the differing
individual IDs are isolated.
4. Output: two lists — `have` (IDs the peer has that we lack) and
`need` (IDs we have that the peer lacks).
When sets overlap ~99%, only a few KB of fingerprints are exchanged instead of
megabytes of `REQ` paging. See the reference implementation
[hoytech/negentropy](https://github.com/hoytech/negentropy) (C++) and the C port
[mcl/negentropy-c](https://github.com/mcl/negentropy-c).
### NIP-77 wire framing
NIP-77 wraps RBSR messages over a Nostr WebSocket:
| Message | Direction | Purpose |
|-------------|------------------|------------------------------------------------------|
| `NEG-OPEN` | client → relay | open a reconciliation session: subscription id, NIP-01 filter, hex `initialMessage` (first RBSR frame) |
| `NEG-MSG` | both directions | carry subsequent RBSR frames (hex-encoded payload) |
| `NEG-CLOSE` | client → relay | terminate the session |
| `NEG-ERR` | relay → client | error (e.g. set too large, malformed) |
The relay builds its negentropy set from `events` matching the filter, runs the
RBSR state machine against the client's frames, and replies with `NEG-MSG`
until convergence.
---
## Design Decisions
### D1: Port, don't vendor as a submodule
**Decision:** Port the RBSR algorithm directly into `nostr_core_lib` as a new
NIP module (`nip077`), rather than adding `mcl/negentropy-c` as a git
submodule.
**Rationale:**
- The library already follows a "port the algorithm, no extra runtime deps"
pattern (see [`nip013.c`](../nostr_core/nip013.c), [`nip044.c`](../nostr_core/nip044.c)).
- The RBSR core is ~600 lines of C++ in the reference; a C port is tractable
and keeps the static MUSL build self-contained (the relay's
[`build_static.sh`](../../c-relay-pg/build_static.sh) must not gain a new
submodule link step).
- Avoids submodule drift / version skew between `nostr_core_lib` and an
external repo.
**Caveat:** We *study* `mcl/negentropy-c` and `hoytech/negentropy` for
correctness and test vectors, and credit them in the header. If the port turns
out to be risky, the fallback is to vendor `mcl/negentropy-c` under
`nostr_core/negentropy/` and wrap it — see the fallback note in Phase 1.
### D2: Record model = `(created_at, 32-byte id)`
RBSR items are `(timestamp, id)` pairs:
- `timestamp``events.created_at` (BIGINT → `uint64_t`).
- `id` → 32-byte raw event ID (hex-decoded from `events.id`).
The fingerprint is computed over the sorted `(timestamp || id)` byte sequence.
This matches the reference implementation's `Record` type exactly.
### D3: No DB code in the library
The library module is **pure algorithm + wire framing**. It operates on
in-memory arrays of `(timestamp, id)` records and byte buffers. The caller
(the relay or caching daemon) is responsible for:
- Running the `SELECT id, created_at FROM events WHERE <filter>` query.
- Populating the record array.
- Sending/receiving the `NEG-*` WebSocket frames.
This keeps the module portable (no libpq dependency in `nostr_core_lib`) and
testable in isolation.
### D4: Fingerprint = 64-bit, frame size configurable
Match the reference: 8-byte fingerprints, 16-byte frame size (min/max range
boundaries per level). Make frame size a parameter so callers can trade
bandwidth for round-trips.
### D5: Memory ownership
The library owns the RBSR state machine (`negentropy_t`) and the record array
it's given a reference to (borrowed, not copied — the caller owns the query
result lifetime). Output `have`/`need` ID lists are allocated by the library
and freed by the caller via a provided `negentropy_id_list_free()`.
---
## File Layout
All new files under [`nostr_core_lib/nostr_core/`](../nostr_core):
```
nostr_core/
├── nip077.h # Public API: RBSR state machine + NIP-77 framing
├── nip077.c # Implementation
├── negentropy.h # Internal: RBSR core (records, ranges, fingerprints)
├── negentropy.c # Internal: RBSR core implementation
└── negentropy_msg.h # Internal: varint + message encode/decode (optional split)
```
Tests:
```
tests/
└── nip077_test.c # Round-trip + known-vector reconciliation tests
```
---
## Public API Sketch (`nip077.h`)
```c
#ifndef NIP077_H
#define NIP077_H
#include <stdint.h>
#include <stddef.h>
/* A single record in the reconcilable set. */
typedef struct {
uint64_t created_at; /* events.created_at */
uint8_t id[32]; /* raw 32-byte event id */
} negentropy_record_t;
/* Opaque RBSR state machine. */
typedef struct negentropy_ctx negentropy_ctx_t;
/* Output of a reconciliation step: the IDs each side is missing. */
typedef struct {
uint8_t (*have)[32]; /* IDs the peer has that we lack */
size_t have_count;
uint8_t (*need)[32]; /* IDs we have that the peer lacks */
size_t need_count;
} negentropy_reconcile_result_t;
/* Create a context over a sorted record array (borrowed, not copied).
* records must remain valid for the lifetime of the ctx.
* Returns NULL on error. */
negentropy_ctx_t *negentropy_new(const negentropy_record_t *records,
size_t record_count,
uint64_t frame_size_bits /* default 16 */);
void negentropy_free(negentropy_ctx_t *ctx);
/* Produce the initial RBSR message (client side sends this in NEG-OPEN).
* Writes a hex string into out_hex (caller-allocated, capacity out_hex_cap).
* Returns bytes written, or -1 on error. */
int negentropy_initiate(const negentropy_ctx_t *ctx,
char *out_hex, size_t out_hex_cap);
/* Feed an incoming RBSR message (hex) and produce the next outbound message.
* If the protocol has converged, returns 0 and sets *converged=1 (out_hex
* may be empty). Otherwise returns bytes written and *converged=0.
* On error returns -1. */
int negentropy_reconcile(negentropy_ctx_t *ctx,
const char *in_hex,
char *out_hex, size_t out_hex_cap,
int *converged,
negentropy_reconcile_result_t *result /* optional */);
void negentropy_result_free(negentropy_reconcile_result_t *r);
/* ---- NIP-77 wire framing helpers (JSON array parse/build) ---- */
/* Parse a NEG-OPEN frame:
* ["NEG-OPEN", subscriptionId, filterJson, initialMessageHex]
* Returns 0 on success. Caller frees *subscription_id and *filter_json. */
int nip77_parse_neg_open(const char *frame_json,
char **subscription_id,
char **filter_json,
char **initial_message_hex);
/* Build a NEG-MSG frame:
* ["NEG-MSG", subscriptionId, payloadHex]
* into out (caller-allocated). */
int nip77_build_neg_msg(const char *subscription_id,
const char *payload_hex,
char *out, size_t out_cap);
/* Build a NEG-ERR frame:
* ["NEG-ERR", subscriptionId, reason] */
int nip77_build_neg_err(const char *subscription_id,
const char *reason,
char *out, size_t out_cap);
#endif /* NIP077_H */
```
---
## Implementation Todo List
### Phase 1: RBSR Core (`negentropy.c` / `.h`)
- [ ] **1.1** Port the data structures from `hoytech/negentropy`:
`Record` (timestamp + id), `Range` (lower/upper bound + mode:
`bounded`/`unbounded`/`fingerprint`), `Fingerprint` (8 bytes).
Use `uint64_t` timestamps and fixed 32-byte ids.
- [ ] **1.2** Implement **varint** encode/decode (LEB128-style as in the
reference). Unit-test edge cases (0, 127, 128, 2^32, 2^64).
- [ ] **1.3** Implement **fingerprint** computation: a 64-bit hash over the
concatenated `(timestamp || id)` bytes of all records in a range. Use
SipHash-2-4 (or the reference's `Fingerprint` algorithm) — match the
reference exactly so frames interoperate with other NIP-77 clients.
*Decision needed:* confirm the exact hash the reference uses (it's a
custom 64-bit accumulator, not SipHash) and replicate it byte-for-byte.
- [ ] **1.4** Implement **set construction**: take a sorted `(timestamp, id)`
array and build the initial range tree / flat sorted view the algorithm
operates on. Records **must** be sorted by `(created_at, id)` — document
this contract and provide a `negentropy_sort_records()` helper using
`qsort`.
- [ ] **1.5** Implement **message encode**: serialize the current protocol
state into a frame (varint-prefixed ranges with bounds + fingerprints).
Match the reference's `encodeBound`/`encodeFingerprint` byte layout.
- [ ] **1.6** Implement **message decode**: parse an incoming frame into a
list of peer ranges. Validate varint bounds and reject malformed frames
(return -1, never crash).
- [ ] **1.7** Implement **reconcile step** (the core loop):
- Walk our ranges vs the peer's ranges.
- Where fingerprints match → skip (both sides equal).
- Where they differ → split into sub-ranges and emit a response frame
drilling down. Use the `frameSizeBits` parameter to decide when to
stop splitting and instead request the full ID list for a small range.
- When a range is small enough, emit `have/need` IDs directly.
- Track `converged` state: no more differing ranges to investigate.
- [ ] **1.8** Implement `negentropy_initiate()` — the first frame a client
sends (a single unbounded range with the whole-set fingerprint).
- [ ] **1.9** Memory: all intermediate range lists are freed at the end of
each `reconcile` call; the ctx holds only the borrowed record array +
config. No leaks across round-trips.
### Phase 2: NIP-77 Wire Framing (`nip077.c` / `.h`)
- [ ] **2.1** Implement `nip77_parse_neg_open()` using
[`cJSON`](../cjson/cJSON.c): validate the 4-element array, extract
`subscriptionId` (string), `filter` (object, returned as a string for
the caller to re-parse with the relay's existing filter logic), and
`initialMessage` (hex string).
- [ ] **2.2** Implement `nip77_build_neg_msg()` and `nip77_build_neg_err()`
as simple cJSON array serializations.
- [ ] **2.3** Add hex encode/decode helpers (32-byte id ↔ 64-char hex) —
reuse [`utils.c`](../nostr_core/utils.c) if a hex helper already
exists there; otherwise add `negentropy_hex_encode/decode` locally.
- [ ] **2.4** Wire the RBSR core to the framing: a helper that takes a
parsed `NEG-OPEN` (filter + initialMessage), builds a
`negentropy_ctx_t` from a caller-provided record array, runs
`negentropy_reconcile()`, and returns the `NEG-MSG`/`NEG-ERR` response
string. This is the convenience function the relay's
[`websockets.c`](../../c-relay-pg/src/websockets.c) handler will call.
### Phase 3: Build System Integration
- [ ] **3.1** Add `nostr_core/nip077.c` and `nostr_core/negentropy.c` to the
`SOURCES` list in [`build.sh`](../build.sh) (the auto-detect loop at
~line 521 and the `--nips=all` list at ~line 201).
- [ ] **3.2** Add the same two files to the `add_library(nostr_core STATIC ...)`
list in [`CMakeLists.txt`](../CMakeLists.txt) (~line 40).
- [ ] **3.3** Add `077` to the `NIP_DESCRIPTIONS` case statement in
`build.sh` (~line 526): `077) NIP_DESCRIPTIONS="$NIP_DESCRIPTIONS NIP-077(Negentropy)" ;;`
- [ ] **3.4** Add `NIP-077(Negentropy)` to the NIP status table in
[`README.md`](../README.md) and [`nostr_core.h`](../nostr_core/nostr_core.h)
quick-reference section.
- [ ] **3.5** Bump [`VERSION`](../VERSION) per the library's
[`increment_and_push.sh`](../increment_and_push.sh) convention once
Phase 12 land.
### Phase 4: Unit Tests (`tests/nip077_test.c`)
Follow the existing test style ([`nip13_test.c`](tests/nip13_test.c)):
`print_test_header` / `print_test_result`, expected-vs-actual, exit code =
fail count.
- [ ] **4.1** Varint round-trip: encode/decode 0, 1, 127, 128, 300,
0xFFFFFFFF, 0xFFFFFFFFFFFFFFFF.
- [ ] **4.2** Fingerprint determinism: same input → same 8 bytes; different
input → different 8 bytes (with high probability).
- [ ] **4.3** Hex encode/decode round-trip for 32-byte ids.
- [ ] **4.4** **Known reconciliation vector** (the critical test): two
contexts A and B with known record sets (use the NIP-77 appendix
example or a vector lifted from `hoytech/negentropy`'s test suite).
Run `initiate``reconcile` → ... → `converged`, and assert:
- A's `need` == IDs B has that A lacks.
- B's `need` == IDs A has that B lacks.
- Both `have`/`need` lists match the expected sets exactly.
- [ ] **4.5** **Identical sets**: A and B hold the same 1000 records →
converge in one round-trip, empty `have`/`need`, minimal bytes.
- [ ] **4.6** **Disjoint sets**: A and B share zero records → converge,
full ID lists exchanged.
- [ ] **4.7** **Large overlap**: A and B share 9990/10000 records → assert
total bytes transferred is small (< a few KB), and the 10 differing
IDs are correctly reconciled. This is the headline bandwidth test.
- [ ] **4.8** **Malformed input**: feed truncated/garbage hex to
`negentropy_reconcile` → returns -1, no crash.
- [ ] **4.9** **NIP-77 framing**: parse a known `NEG-OPEN` JSON, build a
`NEG-MSG`, round-trip through cJSON, assert byte-exact output.
- [ ] **4.10** Register the test in the test build loop in `build.sh`
(auto-discovered via `find tests/ -maxdepth 1 -name "*.c"`).
### Phase 5: Downstream Integration Hooks (reference only — implemented in c-relay-pg)
These are **not** done in `nostr_core_lib`; they're listed here so the API
contract is clear. Tracked in
[`relay_to_relay_sync_plan.md`](../../c-relay-pg/plans/relay_to_relay_sync_plan.md)
Phases 24.
- [ ] **5.1 (relay)** `NEG-OPEN`/`NEG-MSG`/`NEG-CLOSE` dispatch in
[`websockets.c`](../../c-relay-pg/src/websockets.c): build record
array from `SELECT id, created_at FROM events WHERE <filter>` via
[`db_ops_postgres.h`](../../c-relay-pg/src/db_ops_postgres.h), call
the Phase 2.4 helper, send `NEG-MSG`/`NEG-ERR`.
- [ ] **5.2 (relay)** Per-session negentropy state on the `pss` struct,
`negentropy_enabled` + `negentropy_max_records` config keys in the
[`config`](../../c-relay-pg/src/pg_schema.sql) table.
- [ ] **5.3 (caching)** `caching/src/negentropy_sync.c`: client-side
reconciliation loop against peer relays, replacing the
`since`-cursor backfill in [`backfill.c`](../../c-relay-pg/caching/src/backfill.c)
for negentropy-capable peers.
- [ ] **5.4 (both)** Integration test: two local relays converge to the
same event set; bandwidth comparison vs old backfill.
---
## Open Questions
1. **Exact fingerprint algorithm.** The reference uses a custom 64-bit
accumulator, not a standard SipHash. Must confirm the exact byte layout
from `hoytech/negentropy` `Fingerprint` implementation so our frames
interoperate with `strfry`, `nak`, and other NIP-77 clients. **Blocker
for Phase 1.3.**
2. **Frame size default.** Reference defaults to 16 bits (65536-way split).
Confirm this is a good default for relay-scale sets (100k10M events)
or whether we should default larger (e.g. 20 bits) to reduce round-trips
on big relays.
3. **Max-records policy.** Should the library enforce a hard cap on record
array size, or leave that entirely to the caller (relay config
`negentropy_max_records`)? Current design: caller enforces — library is
unbounded. Revisit if we want a built-in safety valve.
4. **Streaming / incremental set loading.** For very large relays, building
the full sorted record array in memory may be heavy (10M records × 40
bytes ≈ 400MB). Do we need a streaming/cursor-based variant that loads
records on demand from the DB cursor during reconcile? Defer to a
follow-up; the initial port assumes an in-memory array.
5. **NIP-77 version negotiation.** NIP-77 has a `version` field in
`NEG-OPEN` (0 = negentropy, 1 = negentropy with timestamps). We need to
support both, or pick one. Reference relays support version 1. **Decide
before Phase 2.1.**
---
## References
- [NIP-77](https://github.com/nostr-protocol/nips/blob/master/77.md)
- [hoytech/negentropy](https://github.com/hoytech/negentropy) (C++ reference)
- [mcl/negentropy-c](https://github.com/mcl/negentropy-c) (C port, study
reference)
- [`c-relay-pg/plans/relay_to_relay_sync_plan.md`](../../c-relay-pg/plans/relay_to_relay_sync_plan.md)
(downstream integration plan, Option C)
- Existing NIP module conventions: [`nip013.c`](../nostr_core/nip013.c),
[`nip044.c`](../nostr_core/nip044.c)
- Build system: [`build.sh`](../build.sh), [`CMakeLists.txt`](../CMakeLists.txt)
- Test conventions: [`tests/nip13_test.c`](tests/nip13_test.c)
+35 -1
View File
@@ -109,10 +109,44 @@ int test_repo_announcement(void) {
int tag_count = tags ? cJSON_GetArraySize(tags) : 0;
printf("Tag count: %d (expected >= 6)\n", tag_count);
// Verify clone tag is a single multi-value tag: ["clone", url1, url2]
int clone_tag_count = 0;
int clone_values_in_first = 0;
// Verify relays tag is a single multi-value tag: ["relays", url1, url2]
int relays_tag_count = 0;
int relays_values_in_first = 0;
if (tags) {
int n = cJSON_GetArraySize(tags);
for (int i = 0; i < n; i++) {
cJSON* tag = cJSON_GetArrayItem(tags, i);
if (tag && cJSON_IsArray(tag)) {
cJSON* name = cJSON_GetArrayItem(tag, 0);
const char* name_str = cJSON_GetStringValue(name);
if (name_str) {
if (strcmp(name_str, "clone") == 0) {
clone_tag_count++;
if (clone_tag_count == 1)
clone_values_in_first = cJSON_GetArraySize(tag) - 1;
} else if (strcmp(name_str, "relays") == 0) {
relays_tag_count++;
if (relays_tag_count == 1)
relays_values_in_first = cJSON_GetArraySize(tag) - 1;
}
}
}
}
}
printf("Clone tags: %d (expected 1), values in first: %d (expected 2)\n",
clone_tag_count, clone_values_in_first);
printf("Relays tags: %d (expected 1), values in first: %d (expected 2)\n",
relays_tag_count, relays_values_in_first);
cJSON_Delete(event);
nostr_signer_free(signer);
int passed = (rc == NOSTR_SUCCESS && kind_val == 30617 && tag_count >= 6);
int passed = (rc == NOSTR_SUCCESS && kind_val == 30617 && tag_count >= 6 &&
clone_tag_count == 1 && clone_values_in_first == 2 &&
relays_tag_count == 1 && relays_values_in_first == 2);
print_test_result(passed, "Repository Announcement");
return passed;
}