Fix NIP-34 tag format: use single multi-value tags for clone, relays, maintainers per NIP-34 spec
This commit is contained in:
+32
-11
@@ -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,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
|
||||
|
||||
@@ -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 2–4 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 1–2 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 2–4.
|
||||
|
||||
- [ ] **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 (100k–10M 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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user