From feca911ac4908723ee3b532e3caee2980e50c3ee Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Tue, 4 Aug 2026 09:06:37 -0400 Subject: [PATCH] Fix NIP-34 tag format: use single multi-value tags for clone, relays, maintainers per NIP-34 spec --- VERSION | 2 +- nostr_core/nip034.c | 43 ++- nostr_core/nostr_core.h | 4 +- plans/negentropy_implementation_plan.md | 391 ++++++++++++++++++++++++ tests/nip34_test.c | 36 ++- 5 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 plans/negentropy_implementation_plan.md diff --git a/VERSION b/VERSION index 592e815e..e196726d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.12 +0.6.13 diff --git a/nostr_core/nip034.c b/nostr_core/nip034.c index f52e4b5f..596a0631 100644 --- a/nostr_core/nip034.c +++ b/nostr_core/nip034.c @@ -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 ["", ""] 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 ["", "", "", ...] 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 ["", "", ""] 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) diff --git a/nostr_core/nostr_core.h b/nostr_core/nostr_core.h index 8930b687..59919099 100644 --- a/nostr_core/nostr_core.h +++ b/nostr_core/nostr_core.h @@ -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 diff --git a/plans/negentropy_implementation_plan.md b/plans/negentropy_implementation_plan.md new file mode 100644 index 00000000..65a10422 --- /dev/null +++ b/plans/negentropy_implementation_plan.md @@ -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 ` 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 +#include + +/* 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 ` 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) diff --git a/tests/nip34_test.c b/tests/nip34_test.c index ba888167..4f10b2d3 100644 --- a/tests/nip34_test.c +++ b/tests/nip34_test.c @@ -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; }